返回 CodeWhale
tests.rs
根目录 / crates / tui / src / integrations / dsh / tests.rs
1 use std::path::PathBuf;
2
3 use super::detect::{
4 DetectEnv, DshDetection, DshRunner, classify_version, detect, settings_namespaces,
5 };
6 use super::identity::{
7 CodewhaleRouteIdentity, DshAdapter, DshPermissionMode, WireProtocol, dsh_reasoning_effort,
8 map_identity, permission_mode_for, render_overlay,
9 };
10 use super::receipt::{DshReceiptDocument, DshReceiptEvent};
11 use super::*;
12
13 struct StubRunner {
14 version: Option<(bool, String)>,
15 help: String,
16 fail: bool,
17 }
18
19 impl DshRunner for StubRunner {
20 fn run(&self, _binary: &std::path::Path, args: &[&str]) -> std::io::Result<(bool, String)> {
21 if self.fail {
22 return Err(std::io::Error::other("cannot exec"));
23 }
24 match args {
25 ["--version"] => Ok(self.version.clone().unwrap_or((false, String::new()))),
26 ["--help"] => Ok((true, self.help.clone())),
27 _ => Ok((false, String::new())),
28 }
29 }
30 }
31
32 fn verified_runner() -> StubRunner {
33 StubRunner {
34 version: Some((true, "0.1.0-rc.6\n".to_string())),
35 help: "Options:\n --profile <name>\n --patch <path>\n".to_string(),
36 fail: false,
37 }
38 }
39
40 fn lab_env(with_dsh: bool) -> (tempfile::TempDir, DetectEnv) {
41 let dir = tempfile::tempdir().unwrap();
42 let bin = dir.path().join("bin");
43 std::fs::create_dir_all(&bin).unwrap();
44 if with_dsh {
45 std::fs::write(bin.join("dsh"), "#!/bin/sh\necho 0.1.0-rc.6\n").unwrap();
46 }
47 let dsh_home = dir.path().join("dsh-home");
48 let env = DetectEnv {
49 path: Some(bin.into_os_string()),
50 home: Some(dir.path().to_path_buf()),
51 dsh_home: Some(dsh_home.into_os_string()),
52 };
53 (dir, env)
54 }
55
56 fn identity(
57 provider: &str,
58 model: &str,
59 base_url: &str,
60 protocol: WireProtocol,
61 ) -> CodewhaleRouteIdentity {
62 CodewhaleRouteIdentity {
63 provider_id: provider.to_string(),
64 provider_label: provider.to_uppercase(),
65 model: model.to_string(),
66 base_url: base_url.to_string(),
67 protocol,
68 api_key_env: Some(format!(
69 "{}_API_KEY",
70 provider.to_uppercase().replace('-', "_")
71 )),
72 keyless_local: false,
73 reasoning_effort: None,
74 sandbox_mode: None,
75 approval_policy: None,
76 yolo: false,
77 workspace: "/ws".to_string(),
78 }
79 }
80
81 #[test]
82 fn version_classification_is_exact_about_the_verified_line() {
83 assert_eq!(
84 classify_version("0.1.0-rc.6", true),
85 DshCompatibility::Verified
86 );
87 assert!(matches!(
88 classify_version("0.1.0-rc.7", true),
89 DshCompatibility::NewerUnverified { .. }
90 ));
91 assert!(matches!(
92 classify_version("0.1.0", true),
93 DshCompatibility::NewerUnverified { .. }
94 ));
95 assert!(matches!(
96 classify_version("0.2.0-rc.1", true),
97 DshCompatibility::NewerUnverified { .. }
98 ));
99 assert!(matches!(
100 classify_version("0.1.0-rc.3", true),
101 DshCompatibility::Incompatible { .. }
102 ));
103 assert!(matches!(
104 classify_version("0.0.1-rc.1", true),
105 DshCompatibility::Incompatible { .. }
106 ));
107 assert!(matches!(
108 classify_version("0.1.0-rc.6", false),
109 DshCompatibility::Incompatible { .. }
110 ));
111 assert!(matches!(
112 classify_version("nightly", true),
113 DshCompatibility::Unparsed { .. }
114 ));
115 }
116
117 #[test]
118 fn detection_reports_missing_offline_and_verified_without_writing() {
119 let (dir, env) = lab_env(false);
120 let d = detect(&env, &verified_runner());
121 assert!(!d.installed());
122 assert!(matches!(d.compatibility, DshCompatibility::Offline { .. }));
123 assert!(!d.dsh_home_exists);
124 assert!(d.dsh_home_from_env);
125
126 let (dir2, env2) = lab_env(true);
127 let d = detect(&env2, &verified_runner());
128 assert!(d.installed());
129 assert_eq!(d.version.as_deref(), Some("0.1.0-rc.6"));
130 assert_eq!(d.compatibility, DshCompatibility::Verified);
131 assert!(d.supports_patch);
132 // Nothing was created under DSH_HOME by detection.
133 assert!(!env2_home(&env2).exists());
134
135 let offline = StubRunner {
136 version: None,
137 help: String::new(),
138 fail: true,
139 };
140 let d = detect(&env2, &offline);
141 assert!(matches!(d.compatibility, DshCompatibility::Offline { .. }));
142 drop(dir);
143 drop(dir2);
144 }
145
146 fn env2_home(env: &DetectEnv) -> PathBuf {
147 PathBuf::from(env.dsh_home.clone().unwrap())
148 }
149
150 #[test]
151 fn detection_inventories_profiles_settings_and_credentials_presence_only() {
152 let (_dir, env) = lab_env(true);
153 let home = env2_home(&env);
154 std::fs::create_dir_all(home.join("profiles/web")).unwrap();
155 std::fs::create_dir_all(home.join("profiles/node_modules")).unwrap();
156 std::fs::write(
157 home.join("settings.yaml"),
158 "ui-onboarding:\n welcomeNoticeVersion: 1\nagent-default-model:\n provider: deepseek-official\n model: deepseek-v4-pro\n",
159 )
160 .unwrap();
161 std::fs::write(
162 home.join(".credentials.yaml"),
163 "DEEPSEEK_API_KEY: not-a-real-key\n",
164 )
165 .unwrap();
166 let d = detect(&env, &verified_runner());
167 assert_eq!(d.profiles, vec!["web".to_string()]);
168 assert_eq!(
169 d.settings_namespaces,
170 vec![
171 "ui-onboarding".to_string(),
172 "agent-default-model".to_string()
173 ]
174 );
175 assert!(d.credentials_present);
176 let json = serde_json::to_string(&d).unwrap();
177 assert!(
178 !json.contains("not-a-real-key"),
179 "detection must never carry a credential value"
180 );
181 }
182
183 #[test]
184 fn settings_namespace_scan_ignores_nested_keys_and_comments() {
185 let ns = settings_namespaces(
186 "# c\nllm-deepseek:\n baseURL: x\n models:\n - id: y\nlocale: en\n- list\n",
187 );
188 assert_eq!(ns, vec!["llm-deepseek", "locale"]);
189 }
190
191 #[test]
192 fn reasoning_effort_maps_onto_dsh_tiers() {
193 assert_eq!(dsh_reasoning_effort(None), None);
194 assert_eq!(dsh_reasoning_effort(Some("off")), Some("off"));
195 assert_eq!(dsh_reasoning_effort(Some("low")), Some("high"));
196 assert_eq!(dsh_reasoning_effort(Some("high")), Some("high"));
197 assert_eq!(dsh_reasoning_effort(Some("ultra")), Some("max"));
198 assert_eq!(dsh_reasoning_effort(Some("max")), Some("max"));
199 assert_eq!(dsh_reasoning_effort(Some("weird")), None);
200 }
201
202 #[test]
203 fn permission_never_broadens_without_explicit_confirmation() {
204 let mut id = identity(
205 "deepseek",
206 "deepseek-v4-pro",
207 "https://api.deepseek.com",
208 WireProtocol::ChatCompletions,
209 );
210 assert_eq!(
211 permission_mode_for(&id, false).0,
212 DshPermissionMode::WorkspaceWrite
213 );
214 id.sandbox_mode = Some("read-only".to_string());
215 assert_eq!(
216 permission_mode_for(&id, false).0,
217 DshPermissionMode::ReadOnly
218 );
219 id.sandbox_mode = Some("danger-full-access".to_string());
220 let (mode, note) = permission_mode_for(&id, false);
221 assert_eq!(mode, DshPermissionMode::WorkspaceWrite);
222 assert!(note.unwrap().contains("--allow-full-access"));
223 assert_eq!(
224 permission_mode_for(&id, true).0,
225 DshPermissionMode::DangerFullAccess
226 );
227 // Codewhale at workspace-write can never be lifted to full access.
228 id.sandbox_mode = Some("workspace-write".to_string());
229 assert_eq!(
230 permission_mode_for(&id, true).0,
231 DshPermissionMode::WorkspaceWrite
232 );
233 }
234
235 #[test]
236 fn deepseek_route_maps_to_native_adapter_with_exact_identity() {
237 let mut id = identity(
238 "deepseek",
239 "deepseek-v4-pro",
240 "https://api.deepseek.com/beta",
241 WireProtocol::ChatCompletions,
242 );
243 id.reasoning_effort = Some("ultra".to_string());
244 let mapped = map_identity(&id, false);
245 assert_eq!(mapped.adapter, DshAdapter::DeepseekNative);
246 assert_eq!(mapped.dsh_reasoning_effort.as_deref(), Some("max"));
247 let overlay = render_overlay(&mapped).unwrap();
248 assert!(overlay.contains("provider: deepseek-official"));
249 assert!(overlay.contains("model: 'deepseek-v4-pro'"));
250 assert!(overlay.contains("baseURL: 'https://api.deepseek.com/beta'"));
251 assert!(overlay.contains("reasoningEffort: max"));
252 assert!(overlay.contains("DeepSeek Harness connected through Codewhale"));
253 assert!(
254 !overlay.contains("apiKeyEnv"),
255 "native adapter resolves its own default key ref"
256 );
257 }
258
259 #[test]
260 fn ollama_keyless_route_writes_no_credential_reference() {
261 let mut id = identity(
262 "ollama",
263 "qwen3:8b",
264 "http://127.0.0.1:11434/v1",
265 WireProtocol::ChatCompletions,
266 );
267 id.keyless_local = true;
268 let mapped = map_identity(&id, false);
269 assert_eq!(
270 mapped.adapter,
271 DshAdapter::PiAiOpenAiCompatible {
272 route_id: "codewhale-ollama".to_string()
273 }
274 );
275 let overlay = render_overlay(&mapped).unwrap();
276 assert!(overlay.contains("provider: 'codewhale-ollama'"));
277 assert!(overlay.contains("api: openai-completions"));
278 assert!(overlay.contains("baseURL: 'http://127.0.0.1:11434/v1'"));
279 assert!(!overlay.contains("apiKeyEnv"));
280 assert!(
281 mapped
282 .disclosures
283 .iter()
284 .any(|d| d.contains("Keyless local route"))
285 );
286 }
287
288 #[test]
289 fn keyed_openai_compatible_route_names_only_the_env_var() {
290 let secret = "sk-this-must-never-appear";
291 let mut id = identity(
292 "zai",
293 "GLM-5.3",
294 "https://api.z.ai/api/coding/paas/v4",
295 WireProtocol::ChatCompletions,
296 );
297 id.api_key_env = Some("ZAI_API_KEY".to_string());
298 id.reasoning_effort = Some("high".to_string());
299 let mapped = map_identity(&id, false);
300 let overlay = render_overlay(&mapped).unwrap();
301 assert!(overlay.contains("apiKeyEnv: 'ZAI_API_KEY'"));
302 assert!(!overlay.contains(secret));
303 assert!(!overlay.contains("reasoningEffort"));
304 let json = serde_json::to_string(&mapped).unwrap();
305 assert!(!json.contains(secret));
306 assert!(mapped.disclosures.iter().any(|d| d.contains("ZAI_API_KEY")));
307 assert!(
308 mapped
309 .disclosures
310 .iter()
311 .any(|d| d.contains("Reasoning tier is not mapped"))
312 );
313 }
314
315 #[test]
316 fn credentialed_base_urls_are_refused_and_the_error_names_the_route() {
317 let id = identity(
318 "custom",
319 "m",
320 "https://user:token@gateway/v1",
321 WireProtocol::ChatCompletions,
322 );
323 let mapped = map_identity(&id, false);
324 match mapped.adapter {
325 DshAdapter::Unsupported { reason } => assert!(reason.contains("userinfo")),
326 other => panic!("expected refusal, got {other:?}"),
327 }
328 let id = identity(
329 "custom",
330 "m",
331 "https://gateway/v1?key=abc",
332 WireProtocol::ChatCompletions,
333 );
334 assert!(!map_identity(&id, false).mappable());
335 // A Responses-dialect route with a credentialed URL is still refused —
336 // carrying the dialect never relaxes the structural-URL guard.
337 let id = identity(
338 "custom",
339 "m",
340 "https://user:token@gateway/v1",
341 WireProtocol::Responses,
342 );
343 assert!(!map_identity(&id, false).mappable());
344
345 // The plan refusal names the current route so it is actionable.
346 let (_dir, paths) = lab_paths();
347 let detection = detection_ok();
348 let id = identity(
349 "custom",
350 "secret-gateway-model",
351 "https://user:token@gateway/v1",
352 WireProtocol::ChatCompletions,
353 );
354 let error = super::plan(&paths, &detection, &id, "web", false, false, true)
355 .expect_err("credentialed URL must refuse");
356 let text = format!("{error:#}");
357 assert!(text.contains("custom/secret-gateway-model"), "{text}");
358 assert!(text.contains("userinfo"), "{text}");
359 }
360
361 #[test]
362 fn responses_dialect_route_is_carried_not_approximated() {
363 // The default DeepSeek route from #5434: deepseek-v4-flash speaks the
364 // Responses dialect at https://api.deepseek.com/beta.
365 let id = identity(
366 "deepseek",
367 "deepseek-v4-flash",
368 "https://api.deepseek.com/beta",
369 WireProtocol::Responses,
370 );
371 let mapped = map_identity(&id, false);
372 assert_eq!(
373 mapped.adapter,
374 DshAdapter::PiAiOpenAiCompatible {
375 route_id: "codewhale-deepseek".to_string()
376 }
377 );
378 assert_eq!(mapped.dsh_provider(), Some("codewhale-deepseek"));
379 let overlay = render_overlay(&mapped).unwrap();
380 assert!(overlay.contains("provider: 'codewhale-deepseek'"));
381 assert!(overlay.contains("api: openai-responses"));
382 assert!(!overlay.contains("api: openai-completions"));
383 assert!(overlay.contains("baseURL: 'https://api.deepseek.com/beta'"));
384 assert!(overlay.contains("apiKeyEnv: 'DEEPSEEK_API_KEY'"));
385 assert!(overlay.contains("model: 'deepseek-v4-flash'"));
386 assert!(
387 mapped
388 .disclosures
389 .iter()
390 .any(|d| d.contains("openai-responses") && d.contains("never approximated"))
391 );
392
393 // And it plans cleanly end-to-end.
394 let (_dir, paths) = lab_paths();
395 let detection = detection_ok();
396 let plan = super::plan(&paths, &detection, &id, "web", false, false, true).unwrap();
397 assert!(plan.overlay_text.contains("api: openai-responses"));
398 assert_eq!(plan.mapped.dsh_provider(), Some("codewhale-deepseek"));
399 }
400
401 #[test]
402 fn anthropic_messages_route_is_carried_in_its_own_dialect() {
403 let id = identity(
404 "anthropic",
405 "claude-sonnet-5",
406 "https://api.anthropic.com",
407 WireProtocol::AnthropicMessages,
408 );
409 let mapped = map_identity(&id, false);
410 assert!(matches!(
411 mapped.adapter,
412 DshAdapter::PiAiOpenAiCompatible { .. }
413 ));
414 let overlay = render_overlay(&mapped).unwrap();
415 assert!(overlay.contains("api: anthropic-messages"));
416 assert!(overlay.contains("apiKeyEnv: 'ANTHROPIC_API_KEY'"));
417 assert!(overlay.contains("baseURL: 'https://api.anthropic.com'"));
418 }
419
420 #[test]
421 fn status_surfaces_route_carryability_before_plan() {
422 let (_dir, paths) = lab_paths();
423 let detection = detection_ok();
424 let id = identity(
425 "deepseek",
426 "deepseek-v4-flash",
427 "https://api.deepseek.com/beta",
428 WireProtocol::Responses,
429 );
430 let report = compute_status(&paths, detection.clone(), Ok(id), false, avail()).unwrap();
431 let line = status_line(&report);
432 assert!(
433 line.contains("deepseek/deepseek-v4-flash is carryable via codewhale-deepseek"),
434 "{line}"
435 );
436
437 let id = identity(
438 "custom",
439 "m",
440 "https://user:token@gateway/v1",
441 WireProtocol::ChatCompletions,
442 );
443 let report = compute_status(&paths, detection, Ok(id), false, avail()).unwrap();
444 let line = status_line(&report);
445 assert!(line.contains("custom/m cannot be carried by DSH"), "{line}");
446 assert!(line.contains("userinfo"), "{line}");
447 }
448
449 #[test]
450 fn overlay_hash_is_deterministic_and_yaml_quotes_apostrophes() {
451 let mut id = identity(
452 "custom",
453 "it's",
454 "http://10.0.0.5:8000/v1",
455 WireProtocol::ChatCompletions,
456 );
457 id.provider_label = "O'Brien Gateway".to_string();
458 let a = render_overlay(&map_identity(&id, false)).unwrap();
459 let b = render_overlay(&map_identity(&id, false)).unwrap();
460 assert_eq!(sha256_hex(a.as_bytes()), sha256_hex(b.as_bytes()));
461 assert!(a.contains("'it''s'"));
462 assert!(a.contains("O''Brien"));
463 }
464
465 fn avail() -> BundleAvailability {
466 BundleAvailability::Available {
467 pnpm_version: "10.23.0".to_string(),
468 }
469 }
470
471 fn lab_paths() -> (tempfile::TempDir, DshPaths) {
472 let dir = tempfile::tempdir().unwrap();
473 let paths = DshPaths::under(&dir.path().join("codewhale-home"));
474 (dir, paths)
475 }
476
477 fn detection_ok() -> DshDetection {
478 let (_dir, env) = lab_env(true);
479 let mut d = detect(&env, &verified_runner());
480 d.binary = Some(PathBuf::from("/fake/dsh"));
481 d
482 }
483
484 #[test]
485 fn connect_update_disable_enable_remove_lifecycle_writes_only_owned_files() {
486 let (_dir, paths) = lab_paths();
487 let detection = detection_ok();
488 let id = identity(
489 "deepseek",
490 "deepseek-v4-flash",
491 "https://api.deepseek.com",
492 WireProtocol::ChatCompletions,
493 );
494
495 // Not connected yet.
496 let report = compute_status(&paths, detection.clone(), Ok(id.clone()), false, avail()).unwrap();
497 assert!(matches!(report.state, DshIntegrationState::Detected { .. }));
498 assert!(launch_spec(&report, None, &[], std::path::Path::new("/ws")).is_err());
499
500 let plan = super::plan(&paths, &detection, &id, "web", false, true, true).unwrap();
501 assert!(plan.overlay_text.contains("deepseek-official"));
502 let record = apply_plan(&paths, &detection, &plan, DshReceiptEvent::Connect).unwrap();
503 assert!(paths.overlay.is_file());
504 assert!(
505 !paths.skin.is_file(),
506 "connect --skin records the palette decision; it does not write a stylesheet"
507 );
508 assert!(!paths.skin_preview.is_file());
509 assert!(paths.receipt.is_file());
510 assert!(record.skin_enabled);
511 assert_eq!(
512 record.skin_sha256.as_deref(),
513 Some(skin::skin_tokens_sha256().as_str())
514 );
515 assert_eq!(record.overlay_sha256, plan.overlay_sha256);
516
517 let report = compute_status(&paths, detection.clone(), Ok(id.clone()), false, avail()).unwrap();
518 assert!(
519 matches!(report.state, DshIntegrationState::Connected { .. }),
520 "{:?}",
521 report.state
522 );
523 let spec = launch_spec(
524 &report,
525 None,
526 &["--port".to_string(), "0".to_string()],
527 std::path::Path::new("/ws"),
528 )
529 .unwrap();
530 assert_eq!(spec.args[0], "--profile");
531 assert_eq!(spec.args[1], "web");
532 assert_eq!(spec.args[2], "--patch");
533 assert!(spec.args[3].ends_with(OVERLAY_FILE));
534 assert_eq!(spec.args[4..], ["--port", "0"]);
535 assert_eq!(
536 spec.env,
537 vec![(
538 "DSH_PERMISSION_MODE".to_string(),
539 "workspace-write".to_string()
540 )]
541 );
542
543 // Route drift → stale-config, launch refused.
544 let mut moved = id.clone();
545 moved.model = "deepseek-v4-pro".to_string();
546 let report =
547 compute_status(&paths, detection.clone(), Ok(moved.clone()), false, avail()).unwrap();
548 assert!(
549 matches!(report.state, DshIntegrationState::StaleConfig { .. }),
550 "{:?}",
551 report.state
552 );
553 let err = launch_spec(&report, None, &[], std::path::Path::new("/ws"))
554 .unwrap_err()
555 .to_string();
556 assert!(err.contains("stale"), "{err}");
557
558 // Update re-derives.
559 let plan2 = super::plan(&paths, &detection, &moved, "web", false, false, true).unwrap();
560 apply_plan(&paths, &detection, &plan2, DshReceiptEvent::Update).unwrap();
561 let report =
562 compute_status(&paths, detection.clone(), Ok(moved.clone()), false, avail()).unwrap();
563 assert!(matches!(
564 report.state,
565 DshIntegrationState::Connected { .. }
566 ));
567
568 // Tampered overlay → stale.
569 std::fs::write(&paths.overlay, "- id: x\n").unwrap();
570 let report =
571 compute_status(&paths, detection.clone(), Ok(moved.clone()), false, avail()).unwrap();
572 assert!(matches!(
573 report.state,
574 DshIntegrationState::StaleConfig { .. }
575 ));
576 apply_plan(&paths, &detection, &plan2, DshReceiptEvent::Update).unwrap();
577
578 // Disable / enable.
579 set_disabled(&paths, true).unwrap();
580 let report =
581 compute_status(&paths, detection.clone(), Ok(moved.clone()), false, avail()).unwrap();
582 assert!(matches!(report.state, DshIntegrationState::Disabled { .. }));
583 assert!(launch_spec(&report, None, &[], std::path::Path::new("/ws")).is_err());
584 set_disabled(&paths, false).unwrap();
585 let report =
586 compute_status(&paths, detection.clone(), Ok(moved.clone()), false, avail()).unwrap();
587 assert!(matches!(
588 report.state,
589 DshIntegrationState::Connected { .. }
590 ));
591
592 // Remove: files gone, history kept, current cleared.
593 let removed = remove(&paths).unwrap();
594 assert!(removed.contains(&paths.overlay));
595 assert!(!paths.overlay.exists());
596 assert!(!paths.skin.exists());
597 assert!(!paths.skin_preview.exists());
598 let doc = DshReceiptDocument::load(&paths.receipt).unwrap();
599 assert!(doc.current.is_none());
600 let events: Vec<_> = doc.history.iter().map(|e| e.event.as_str()).collect();
601 assert_eq!(
602 events,
603 ["connect", "update", "update", "disable", "enable", "remove"]
604 );
605 let report = compute_status(&paths, detection, Ok(moved), false, avail()).unwrap();
606 assert!(matches!(report.state, DshIntegrationState::Detected { .. }));
607 // Every write stayed under the integration root.
608 for entry in walk(&paths.root.parent().unwrap().parent().unwrap().to_path_buf()) {
609 assert!(
610 entry.starts_with(&paths.root),
611 "unexpected file {}",
612 entry.display()
613 );
614 }
615 }
616
617 fn walk(root: &PathBuf) -> Vec<PathBuf> {
618 let mut out = Vec::new();
619 if let Ok(entries) = std::fs::read_dir(root) {
620 for entry in entries.flatten() {
621 let path = entry.path();
622 if path.is_dir() {
623 out.extend(walk(&path));
624 } else {
625 out.push(path);
626 }
627 }
628 }
629 out
630 }
631
632 #[test]
633 fn newer_dsh_reports_stale_version_but_stays_launchable() {
634 let (_dir, paths) = lab_paths();
635 let mut detection = detection_ok();
636 let id = identity(
637 "deepseek",
638 "deepseek-v4-flash",
639 "https://api.deepseek.com",
640 WireProtocol::ChatCompletions,
641 );
642 let plan = super::plan(&paths, &detection, &id, "headless", false, false, true).unwrap();
643 apply_plan(&paths, &detection, &plan, DshReceiptEvent::Connect).unwrap();
644 detection.version = Some("0.1.0-rc.9".to_string());
645 detection.compatibility = classify_version("0.1.0-rc.9", true);
646 let report = compute_status(&paths, detection, Ok(id), false, avail()).unwrap();
647 assert!(matches!(
648 report.state,
649 DshIntegrationState::StaleVersion { .. }
650 ));
651 assert!(report.state.launchable());
652 let spec = launch_spec(&report, None, &[], std::path::Path::new("/ws")).unwrap();
653 assert_eq!(spec.args[1], "headless");
654 }
655
656 #[test]
657 fn incompatible_and_missing_dsh_states_are_honest() {
658 let (_dir, paths) = lab_paths();
659 let mut detection = detection_ok();
660 detection.version = Some("0.0.1-rc.1".to_string());
661 detection.compatibility = classify_version("0.0.1-rc.1", true);
662 let id = identity(
663 "deepseek",
664 "m",
665 "https://api.deepseek.com",
666 WireProtocol::ChatCompletions,
667 );
668 let report = compute_status(&paths, detection.clone(), Ok(id.clone()), false, avail()).unwrap();
669 assert!(matches!(
670 report.state,
671 DshIntegrationState::Incompatible { .. }
672 ));
673 assert!(status_line(&report).starts_with("incompatible"));
674 detection.binary = None;
675 let report = compute_status(&paths, detection, Ok(id), false, avail()).unwrap();
676 assert_eq!(report.state, DshIntegrationState::NotInstalled);
677 assert!(status_line(&report).contains("not installed"));
678 }
679
680 #[test]
681 fn plan_discloses_shadowing_settings_namespaces() {
682 let (_dir, paths) = lab_paths();
683 let mut detection = detection_ok();
684 detection.settings_namespaces = vec!["agent-default-model".to_string(), "locale".to_string()];
685 let id = identity(
686 "deepseek",
687 "m",
688 "https://api.deepseek.com",
689 WireProtocol::ChatCompletions,
690 );
691 let plan = super::plan(&paths, &detection, &id, "web", false, false, true).unwrap();
692 assert_eq!(plan.shadowing_namespaces, vec!["agent-default-model"]);
693 assert!(plan.disclosures.iter().any(|d| d.contains("shadow")));
694 assert!(
695 plan.launch_command
696 .contains("DSH_PERMISSION_MODE=workspace-write dsh --profile web --patch")
697 );
698 }
699
700 #[test]
701 fn skin_token_table_is_alias_pairs_that_round_trip_json() {
702 let table = skin::skin_tokens();
703 assert!(!table.is_empty());
704 for (key, (light, dark)) in &table {
705 assert!(
706 key.starts_with("--dsw-alias-"),
707 "token key must be a DSH alias, got {key}"
708 );
709 assert!(!light.is_empty(), "{key} light is empty");
710 assert!(!dark.is_empty(), "{key} dark is empty");
711 }
712 let bg = table.get("--dsw-alias-bg-base").expect("bg-base is mapped");
713 let label = table
714 .get("--dsw-alias-label-primary")
715 .expect("label-primary is mapped");
716 assert_ne!(bg.0, bg.1, "light and dark surface colors must differ");
717 assert_ne!(
718 label.0, label.1,
719 "light and dark primary labels must differ"
720 );
721 let parsed: std::collections::BTreeMap<String, skin::SkinTokens> =
722 serde_json::from_str(&skin::skin_tokens_json()).unwrap();
723 let round_trip: std::collections::BTreeMap<String, (String, String)> = parsed
724 .into_iter()
725 .map(|(k, v)| (k, (v.light, v.dark)))
726 .collect();
727 assert_eq!(round_trip, table);
728 }
729
730 #[test]
731 fn skin_flag_does_not_change_the_patch_overlay_bytes() {
732 let (_dir, paths) = lab_paths();
733 let detection = detection_ok();
734 let id = identity(
735 "deepseek",
736 "deepseek-v4-flash",
737 "https://api.deepseek.com",
738 WireProtocol::ChatCompletions,
739 );
740 let on = super::plan(&paths, &detection, &id, "web", false, true, true).unwrap();
741 let off = super::plan(&paths, &detection, &id, "web", false, false, true).unwrap();
742 assert_eq!(on.overlay_text, off.overlay_text);
743 assert_eq!(on.overlay_sha256, off.overlay_sha256);
744 assert!(on.skin);
745 assert!(!off.skin);
746 assert!(on.skin_path.is_none());
747 assert!(off.skin_path.is_none());
748 }
749
750 #[test]
751 fn brand_lockup_css_rules_are_not_accidentally_nested() {
752 // The Signal Current mark's `svg` rule was authored *inside* the
753 // `#codewhale-brand-mark { ... }` block. CSS nesting resolves a bare
754 // nested selector against its parent, so `#codewhale-brand-mark svg`
755 // nested under `#codewhale-brand-mark` means
756 // "#codewhale-brand-mark #codewhale-brand-mark svg" — which matches
757 // nothing, and the mark silently fell back to its inline width/height
758 // attributes. Brace depth is the cheap invariant that catches it.
759 let js = skin::bundle_client_js(true);
760 let css_start = js
761 .find("#codewhale-brand-lockup{")
762 .expect("brand lockup css block");
763 // Walk only the concatenated CSS string literals for the brand block.
764 let css_region = &js[css_start..];
765 let end = css_region
766 .find("return React.createElement(")
767 .unwrap_or(css_region.len());
768 let css_region = &css_region[..end];
769
770 let mut depth = 0i32;
771 let mut max_depth = 0i32;
772 for ch in css_region.chars() {
773 match ch {
774 '{' => {
775 depth += 1;
776 max_depth = max_depth.max(depth);
777 }
778 '}' => depth -= 1,
779 _ => {}
780 }
781 if depth < 0 {
782 panic!("unbalanced brace in brand lockup css");
783 }
784 }
785 assert_eq!(depth, 0, "brand lockup css must close every rule it opens");
786 // A media query is the only legitimate nesting level in this sheet.
787 assert!(
788 max_depth <= 2,
789 "brand lockup css nests {max_depth} deep; only @media may nest, \
790 so a selector was authored inside a declaration block"
791 );
792
793 // The mark's own sizing rule must be a top-level rule, not nested: it is
794 // what makes the inline SVG a block box inside the grid cell. In the
795 // emitted CSS that means it is preceded by a closing brace, never by a
796 // declaration.
797 let marker = "#codewhale-brand-mark svg{display:block;width:34px;height:34px;}";
798 let at = css_region
799 .find(marker)
800 .expect("the mark's svg sizing rule must be emitted");
801 // The bundle embeds this file's JS source verbatim, so the text before the
802 // rule still carries string-concatenation punctuation. Strip that and the
803 // last meaningful CSS character must be a closing brace.
804 let preceding: String = css_region[..at]
805 .chars()
806 .filter(|c| !c.is_whitespace() && *c != '"' && *c != '+')
807 .collect();
808 assert!(
809 preceding.ends_with('}'),
810 "the mark's svg rule must follow a closing brace, not sit inside a \
811 declaration block; it is preceded by: {:?}",
812 &preceding[preceding.len().saturating_sub(60)..]
813 );
814 }
815
816 #[test]
817 fn bundle_client_js_is_deterministic_override_tokens_and_not_a_stylesheet() {
818 let a = skin::bundle_client_js(true);
819 let b = skin::bundle_client_js(true);
820 assert_eq!(a, b);
821 assert!(a.contains(&format!("codewhale-skin/{}", env!("CARGO_PKG_VERSION"))));
822 assert!(a.contains(skin::SKIN_SOURCE));
823 assert!(a.contains("ctx.effect"));
824 assert!(a.contains("overrideTokens"));
825 assert!(a.contains("function CodewhaleBrand()"));
826 assert!(a.contains("ctx.slots.inject(\"shell.overlay\""));
827 assert!(a.contains("ctx.slots.register("));
828 assert!(a.contains("React.createElement(CodewhaleBrand)"));
829 assert!(a.contains("if (!ctx.theme || !ctx.slots) return;"));
830 assert!(
831 a.contains("exports.inject = [\"theme\", \"slots\"];"),
832 "cordis exposes ctx.theme and ctx.slots only through inject"
833 );
834 assert!(
835 a.contains("ctx.theme?.overrideTokens"),
836 "disposal shape: effect callback returns the overrideTokens disposer"
837 );
838 assert!(!a.contains("skin_css"));
839 assert!(!a.contains("<style"));
840 assert!(!a.contains("skin_preview"));
841 // TOKENS JSON inside the script is the same table.
842 let start = a.find("const TOKENS = ").expect("TOKENS literal");
843 let json_start = a[start..].find('{').expect("{") + start;
844 let mut depth = 0i32;
845 let mut json_end = json_start;
846 for (i, ch) in a[json_start..].char_indices() {
847 match ch {
848 '{' => depth += 1,
849 '}' => {
850 depth -= 1;
851 if depth == 0 {
852 json_end = json_start + i + 1;
853 break;
854 }
855 }
856 _ => {}
857 }
858 }
859 let tokens_json = &a[json_start..json_end];
860 let parsed: std::collections::BTreeMap<String, skin::SkinTokens> =
861 serde_json::from_str(tokens_json).unwrap();
862 assert_eq!(parsed, skin::skin_token_objects());
863 }
864
865 #[test]
866 fn launch_strips_only_codewhale_injected_credentials() {
867 let none = launch_env_strip_list(None, &["ZAI_API_KEY".to_string()]);
868 assert_eq!(
869 none,
870 [
871 "CODEWHALE_CLI_API_KEY",
872 "CODEWHALE_CLI_API_KEY_SOURCE",
873 "DEEPSEEK_API_KEY_SOURCE"
874 ]
875 );
876 let cli = launch_env_strip_list(Some("cli"), &["ZAI_API_KEY".to_string()]);
877 assert!(cli.contains(&"ZAI_API_KEY".to_string()));
878 assert!(
879 !cli.contains(&"DEEPSEEK_API_KEY".to_string()),
880 "a bridged Z.ai credential must not claim or strip DeepSeek's slot"
881 );
882 let env = launch_env_strip_list(Some("env"), &["ZAI_API_KEY".to_string()]);
883 assert!(
884 !env.contains(&"DEEPSEEK_API_KEY".to_string()),
885 "a user's own env key is left alone"
886 );
887 }
888
889 /// Stub that records `dsh plugin` invocations and simulates DSH writing the
890 /// dedicated profile manifest.
891 struct PluginRunner {
892 profile_dir: PathBuf,
893 calls: std::cell::RefCell<Vec<Vec<String>>>,
894 fail_add: bool,
895 }
896
897 impl DshRunner for PluginRunner {
898 fn run(&self, _binary: &std::path::Path, args: &[&str]) -> std::io::Result<(bool, String)> {
899 let owned: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
900 self.calls.borrow_mut().push(owned.clone());
901 match args {
902 ["--version"] => Ok((true, "0.1.0-rc.6\n".to_string())),
903 ["--help"] => Ok((true, "--patch\n".to_string())),
904 ["plugin", "--profile", "codewhale", "add", spec] => {
905 if self.fail_add {
906 return Ok((false, "ERR_PNPM_NO_MATCHING_VERSION\n".to_string()));
907 }
908 std::fs::create_dir_all(&self.profile_dir).unwrap();
909 let manifest = self.profile_dir.join("package.json");
910 let mut bundles: Vec<String> = bundle::profile_bundles(&self.profile_dir)
911 .unwrap_or_else(|| vec!["@deepseek-ai/dsh-base".to_string()]);
912 let name = if spec.ends_with("dsh-web-app") {
913 "@deepseek-ai/dsh-web-app".to_string()
914 } else {
915 bundle::BUNDLE_PACKAGE_NAME.to_string()
916 };
917 if !bundles.contains(&name) {
918 bundles.push(name);
919 }
920 let json = serde_json::json!({"name": "dsh-profile-codewhale", "private": true, "dsh": {"profile": {"bundles": bundles}}});
921 std::fs::write(manifest, serde_json::to_string_pretty(&json).unwrap()).unwrap();
922 Ok((
923 true,
924 format!("+ {spec} link:\nDone in 100ms using pnpm v10.23.0\n"),
925 ))
926 }
927 ["plugin", "--profile", "codewhale", "remove", name] => {
928 let mut bundles = bundle::profile_bundles(&self.profile_dir).unwrap_or_default();
929 bundles.retain(|b| b != name);
930 let json = serde_json::json!({"name": "dsh-profile-codewhale", "private": true, "dsh": {"profile": {"bundles": bundles}}});
931 std::fs::write(
932 self.profile_dir.join("package.json"),
933 serde_json::to_string_pretty(&json).unwrap(),
934 )
935 .unwrap();
936 Ok((true, "- codewhale-dsh-bundle\n".to_string()))
937 }
938 _ => Ok((false, String::new())),
939 }
940 }
941 }
942
943 /// A fake installed launcher tree so `app_bundle_source` resolves.
944 fn fake_launcher(dir: &std::path::Path) -> PathBuf {
945 // Unix npm: <prefix>/bin/dsh -> <prefix>/lib/node_modules/@deepseek-ai/dsh/lib/bin.js
946 // Windows npm: <prefix>\dsh.cmd shim beside <prefix>\node_modules\@deepseek-ai\dsh
947 #[cfg(unix)]
948 let root = dir.join("npm/lib/node_modules/@deepseek-ai/dsh");
949 #[cfg(not(unix))]
950 let root = dir.join("npm/node_modules/@deepseek-ai/dsh");
951 std::fs::create_dir_all(root.join("lib")).unwrap();
952 std::fs::write(
953 root.join("package.json"),
954 "{\"name\":\"@deepseek-ai/dsh\",\"version\":\"0.1.0-rc.6\"}",
955 )
956 .unwrap();
957 std::fs::write(root.join("lib/bin.js"), "// launcher").unwrap();
958 let app = root.join("node_modules/@deepseek-ai/dsh-web-app");
959 std::fs::create_dir_all(&app).unwrap();
960 std::fs::write(app.join("package.json"), "{\"name\":\"@deepseek-ai/dsh-web-app\",\"dsh\":{\"bundle\":{\"patch\":\"./cordis.patch.yml\"}}}").unwrap();
961 #[cfg(unix)]
962 let bin = dir.join("bin");
963 #[cfg(not(unix))]
964 let bin = dir.join("npm");
965 std::fs::create_dir_all(&bin).unwrap();
966 #[cfg(unix)]
967 std::os::unix::fs::symlink(root.join("lib/bin.js"), bin.join("dsh")).unwrap();
968 #[cfg(not(unix))]
969 std::fs::copy(root.join("lib/bin.js"), bin.join("dsh")).unwrap();
970 bin.join("dsh")
971 }
972
973 #[test]
974 fn launcher_package_root_resolves_a_copied_shim_beside_node_modules() {
975 // The npm-on-Windows layout: no symlink, the shim sits next to the
976 // prefix's node_modules. Exercised on every platform with a plain copy.
977 let temp = tempfile::tempdir().unwrap();
978 let prefix = temp.path().join("npm");
979 let root = prefix.join("node_modules/@deepseek-ai/dsh");
980 std::fs::create_dir_all(root.join("lib")).unwrap();
981 std::fs::write(
982 root.join("package.json"),
983 "{\"name\":\"@deepseek-ai/dsh\",\"version\":\"0.1.0-rc.6\"}",
984 )
985 .unwrap();
986 std::fs::write(root.join("lib/bin.js"), "// launcher").unwrap();
987 std::fs::copy(root.join("lib/bin.js"), prefix.join("dsh")).unwrap();
988 let found = super::bundle::launcher_package_root(&prefix.join("dsh")).expect("root");
989 assert_eq!(
990 std::fs::canonicalize(found).unwrap(),
991 std::fs::canonicalize(root).unwrap()
992 );
993 // A shim with no package beside it and no symlink resolves to nothing.
994 let lonely = temp.path().join("lonely");
995 std::fs::create_dir_all(&lonely).unwrap();
996 std::fs::write(lonely.join("dsh"), "// shim").unwrap();
997 assert!(super::bundle::launcher_package_root(&lonely.join("dsh")).is_none());
998 }
999
1000 #[test]
1001 fn bundle_availability_reports_pnpm_truthfully() {
1002 let (_dir, env) = lab_env(true);
1003 let no_pnpm = bundle::bundle_availability(env.path.as_ref(), &verified_runner());
1004 assert!(
1005 matches!(no_pnpm, BundleAvailability::NotAvailable { ref reason } if reason.contains("pnpm missing"))
1006 );
1007 let bin = PathBuf::from(env.path.clone().unwrap());
1008 std::fs::write(bin.join("pnpm"), "#!/bin/sh\necho 10.23.0\n").unwrap();
1009 struct Pnpm;
1010 impl DshRunner for Pnpm {
1011 fn run(&self, _b: &std::path::Path, args: &[&str]) -> std::io::Result<(bool, String)> {
1012 assert_eq!(args, ["--version"]);
1013 Ok((true, "10.23.0\n".to_string()))
1014 }
1015 }
1016 assert_eq!(
1017 bundle::bundle_availability(env.path.as_ref(), &Pnpm),
1018 BundleAvailability::Available {
1019 pnpm_version: "10.23.0".to_string()
1020 }
1021 );
1022 }
1023
1024 #[test]
1025 fn bundle_files_are_npm_shaped_and_carry_the_overlay_rows() {
1026 let files = bundle::render_bundle_files("0.9.8", "- id: agent-default-model\n", false, true);
1027 let names: Vec<_> = files.iter().map(|(n, _)| *n).collect();
1028 assert_eq!(
1029 names,
1030 ["package.json", "cordis.patch.yml", "README.md", "NOTICE.md"]
1031 );
1032 let pkg: serde_json::Value = serde_json::from_str(&files[0].1).unwrap();
1033 assert_eq!(pkg["name"], "codewhale-dsh-bundle");
1034 assert_eq!(pkg["private"], true);
1035 assert_eq!(pkg["license"], "MIT");
1036 assert_eq!(pkg["dsh"]["bundle"]["patch"], "./cordis.patch.yml");
1037 assert!(pkg["dsh"].get("client").is_none());
1038 assert!(pkg.get("exports").is_none());
1039 assert!(pkg["version"].as_str().unwrap().starts_with("0.9.8+dsh."));
1040 assert_eq!(files[1].1, "- id: agent-default-model\n");
1041 assert!(!files[1].1.contains("insert:"));
1042 assert!(files[3].1.contains("Copyright (c) 2026 DeepSeek"));
1043 }
1044
1045 #[test]
1046 fn bundle_files_with_skin_carry_client_half_and_insert_row() {
1047 let overlay = "- id: agent-default-model\n";
1048 let files = bundle::render_bundle_files("0.9.8", overlay, true, true);
1049 let by_name: std::collections::BTreeMap<&str, &str> =
1050 files.iter().map(|(n, t)| (*n, t.as_str())).collect();
1051 let pkg: serde_json::Value = serde_json::from_str(by_name["package.json"]).unwrap();
1052 let inject = pkg["dsh"]["client"]["inject"]
1053 .as_array()
1054 .expect("dsh.client.inject");
1055 assert!(
1056 inject
1057 .iter()
1058 .any(|v| v.as_str() == Some("@deepseek-ai/dsh-client-ui-theme"))
1059 );
1060 assert_eq!(pkg["dsh"]["client"]["platform"], "web");
1061 assert_eq!(pkg["dsh"]["client"]["immediately"], true);
1062 assert_eq!(pkg["exports"]["./client"]["default"], "./lib/client.js");
1063 // Node's exports map is exhaustive: the loader imports the bare name and
1064 // dsh-client-modules resolves `<name>/package.json`.
1065 assert_eq!(pkg["exports"]["."]["default"], "./lib/index.js");
1066 assert_eq!(pkg["exports"]["./package.json"], "./package.json");
1067 assert!(by_name[bundle::BUNDLE_PATCH_FILE].ends_with(bundle::SKIN_INSERT_YAML));
1068 assert_eq!(
1069 by_name[bundle::BUNDLE_CLIENT_FILE],
1070 skin::bundle_client_js(true)
1071 );
1072 assert_eq!(by_name[bundle::BUNDLE_INDEX_FILE], skin::bundle_index_js());
1073 assert!(!by_name[bundle::BUNDLE_CLIENT_FILE].contains("<style"));
1074 }
1075
1076 #[test]
1077 fn install_update_remove_bundle_lifecycle_uses_documented_plugin_commands() {
1078 let (dir, paths) = lab_paths();
1079 let dsh_bin = fake_launcher(dir.path());
1080 let mut detection = detection_ok();
1081 detection.binary = Some(dsh_bin);
1082 detection.dsh_home = dir.path().join("dsh-home");
1083 let profile_dir = detection.dsh_home.join("profiles").join("codewhale");
1084 let runner = PluginRunner {
1085 profile_dir: profile_dir.clone(),
1086 calls: Default::default(),
1087 fail_add: false,
1088 };
1089 let id = identity(
1090 "deepseek",
1091 "deepseek-v4-flash",
1092 "https://api.deepseek.com",
1093 WireProtocol::ChatCompletions,
1094 );
1095
1096 // Not connected → refused.
1097 assert!(install_bundle(&paths, &detection, &runner, &avail(), DshAppBundle::Web).is_err());
1098 let plan = super::plan(&paths, &detection, &id, "web", false, false, true).unwrap();
1099 apply_plan(&paths, &detection, &plan, DshReceiptEvent::Connect).unwrap();
1100
1101 // pnpm missing → truthful refusal, nothing written.
1102 let err = install_bundle(
1103 &paths,
1104 &detection,
1105 &runner,
1106 &BundleAvailability::NotAvailable {
1107 reason: "pnpm missing from PATH".into(),
1108 },
1109 DshAppBundle::Web,
1110 )
1111 .unwrap_err()
1112 .to_string();
1113 assert!(err.contains("pnpm missing"));
1114 assert!(!paths.bundle_dir.exists());
1115
1116 let record = install_bundle(&paths, &detection, &runner, &avail(), DshAppBundle::Web).unwrap();
1117 assert_eq!(record.profile, "codewhale");
1118 assert_eq!(record.patch_sha256, plan.overlay_sha256);
1119 assert!(paths.bundle_dir.join("cordis.patch.yml").is_file());
1120 assert_eq!(
1121 std::fs::read_to_string(paths.bundle_dir.join("cordis.patch.yml")).unwrap(),
1122 bundle::render_bundle_patch(&plan.overlay_text, true)
1123 );
1124 assert!(paths.bundle_dir.join(bundle::BUNDLE_CLIENT_FILE).is_file());
1125 let installed = DshReceiptDocument::load(&paths.receipt)
1126 .unwrap()
1127 .current
1128 .unwrap();
1129 let installed_json = serde_json::to_value(&installed).unwrap();
1130 assert_eq!(installed_json["skin"], true);
1131 assert_eq!(installed_json["skin_sha256"], skin::skin_tokens_sha256());
1132 assert!(installed.skin_enabled);
1133 assert_eq!(
1134 installed.skin_sha256.as_deref(),
1135 Some(skin::skin_tokens_sha256().as_str())
1136 );
1137 let calls = runner.calls.borrow().clone();
1138 let plugin_calls: Vec<_> = calls.iter().filter(|c| c[0] == "plugin").collect();
1139 assert_eq!(plugin_calls.len(), 2);
1140 assert!(
1141 plugin_calls[0][4].ends_with("dsh-web-app"),
1142 "app bundle first: {plugin_calls:?}"
1143 );
1144 assert_eq!(plugin_calls[1][4], paths.bundle_dir.display().to_string());
1145 assert_eq!(
1146 bundle::profile_bundles(&profile_dir).unwrap(),
1147 [
1148 "@deepseek-ai/dsh-base",
1149 "@deepseek-ai/dsh-web-app",
1150 "codewhale-dsh-bundle"
1151 ]
1152 );
1153
1154 // Connected + launch prefers the bundle profile without --patch.
1155 let report = compute_status(&paths, detection.clone(), Ok(id.clone()), false, avail()).unwrap();
1156 assert!(
1157 matches!(report.state, DshIntegrationState::Connected { .. }),
1158 "{:?}",
1159 report.state
1160 );
1161 let spec = launch_spec(&report, None, &[], std::path::Path::new("/ws")).unwrap();
1162 assert_eq!(spec.args, ["--profile", "codewhale"]);
1163 let spec = launch_spec(&report, Some("web"), &[], std::path::Path::new("/ws")).unwrap();
1164 assert_eq!(spec.args[0..3], ["--profile", "web", "--patch"]);
1165 assert!(status_line(&report).contains("bundle in profile `codewhale`"));
1166
1167 // Route drift → stale (covers the bundle), update rewrites the bundle patch.
1168 let mut moved = id.clone();
1169 moved.model = "deepseek-v4-pro".to_string();
1170 let report =
1171 compute_status(&paths, detection.clone(), Ok(moved.clone()), false, avail()).unwrap();
1172 assert!(matches!(
1173 report.state,
1174 DshIntegrationState::StaleConfig { .. }
1175 ));
1176 let plan2 = super::plan(&paths, &detection, &moved, "web", false, false, true).unwrap();
1177 apply_plan(&paths, &detection, &plan2, DshReceiptEvent::Update).unwrap();
1178 assert_eq!(
1179 std::fs::read_to_string(paths.bundle_dir.join("cordis.patch.yml")).unwrap(),
1180 plan2.overlay_text
1181 );
1182 assert!(
1183 !paths.bundle_dir.join(bundle::BUNDLE_CLIENT_FILE).exists(),
1184 "update --skin false drops the client half"
1185 );
1186 assert!(
1187 !std::fs::read_to_string(paths.bundle_dir.join("cordis.patch.yml"))
1188 .unwrap()
1189 .contains("insert:")
1190 );
1191 let report =
1192 compute_status(&paths, detection.clone(), Ok(moved.clone()), false, avail()).unwrap();
1193 assert!(
1194 matches!(report.state, DshIntegrationState::Connected { .. }),
1195 "{:?}",
1196 report.state
1197 );
1198 assert_eq!(
1199 report
1200 .record
1201 .as_ref()
1202 .unwrap()
1203 .bundle
1204 .as_ref()
1205 .unwrap()
1206 .patch_sha256,
1207 plan2.overlay_sha256
1208 );
1209
1210 // Tampered bundle patch → stale.
1211 std::fs::write(paths.bundle_dir.join("cordis.patch.yml"), "- id: x\n").unwrap();
1212 let report =
1213 compute_status(&paths, detection.clone(), Ok(moved.clone()), false, avail()).unwrap();
1214 assert!(
1215 matches!(report.state, DshIntegrationState::StaleConfig { ref reason, .. } if reason.contains("bundle"))
1216 );
1217 apply_plan(&paths, &detection, &plan2, DshReceiptEvent::Update).unwrap();
1218
1219 // `remove` refuses while the bundle is installed.
1220 assert!(
1221 remove(&paths)
1222 .unwrap_err()
1223 .to_string()
1224 .contains("remove-bundle")
1225 );
1226
1227 // remove-bundle: documented remove, owned files gone, profile dir left.
1228 let removed = remove_bundle(&paths, &detection, &runner).unwrap();
1229 assert!(!removed.is_empty());
1230 assert!(!paths.bundle_dir.join("cordis.patch.yml").exists());
1231 assert!(profile_dir.is_dir(), "DSH profile dir is left in place");
1232 assert_eq!(
1233 bundle::profile_bundles(&profile_dir).unwrap(),
1234 ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]
1235 );
1236 let last = runner.calls.borrow().last().cloned().unwrap();
1237 assert_eq!(
1238 last,
1239 [
1240 "plugin",
1241 "--profile",
1242 "codewhale",
1243 "remove",
1244 "codewhale-dsh-bundle"
1245 ]
1246 );
1247 let doc = DshReceiptDocument::load(&paths.receipt).unwrap();
1248 assert!(doc.current.as_ref().unwrap().bundle.is_none());
1249 let events: Vec<_> = doc.history.iter().map(|e| e.event.as_str()).collect();
1250 assert_eq!(
1251 events,
1252 [
1253 "connect",
1254 "install_bundle",
1255 "update",
1256 "update",
1257 "remove_bundle"
1258 ]
1259 );
1260 // Launch falls back to the overlay path.
1261 let report = compute_status(&paths, detection.clone(), Ok(moved), false, avail()).unwrap();
1262 let spec = launch_spec(&report, None, &[], std::path::Path::new("/ws")).unwrap();
1263 assert_eq!(spec.args[0..3], ["--profile", "web", "--patch"]);
1264 // Now plain remove works.
1265 remove(&paths).unwrap();
1266 }
1267
1268 #[test]
1269 fn failed_plugin_add_leaves_no_bundle_record_or_files() {
1270 let (dir, paths) = lab_paths();
1271 let dsh_bin = fake_launcher(dir.path());
1272 let mut detection = detection_ok();
1273 detection.binary = Some(dsh_bin);
1274 detection.dsh_home = dir.path().join("dsh-home");
1275 let runner = PluginRunner {
1276 profile_dir: detection.dsh_home.join("profiles/codewhale"),
1277 calls: Default::default(),
1278 fail_add: true,
1279 };
1280 let id = identity(
1281 "deepseek",
1282 "deepseek-v4-flash",
1283 "https://api.deepseek.com",
1284 WireProtocol::ChatCompletions,
1285 );
1286 let plan = super::plan(&paths, &detection, &id, "web", false, false, true).unwrap();
1287 apply_plan(&paths, &detection, &plan, DshReceiptEvent::Connect).unwrap();
1288 let err = install_bundle(&paths, &detection, &runner, &avail(), DshAppBundle::Web)
1289 .unwrap_err()
1290 .to_string();
1291 assert!(err.contains("failed"), "{err}");
1292 assert!(!paths.bundle_dir.join("package.json").exists());
1293 let doc = DshReceiptDocument::load(&paths.receipt).unwrap();
1294 assert!(doc.current.as_ref().unwrap().bundle.is_none());
1295 }
1296
1297 #[test]
1298 fn client_half_stale_covers_present_absent_and_modified() {
1299 let dir = tempfile::tempdir().unwrap();
1300 let bundle_dir = dir.path().join("bundle");
1301 std::fs::create_dir_all(bundle_dir.join("lib")).unwrap();
1302
1303 assert!(
1304 bundle::client_half_stale(&bundle_dir, true, true)
1305 .unwrap()
1306 .contains("missing")
1307 );
1308 assert!(bundle::client_half_stale(&bundle_dir, false, true).is_none());
1309
1310 std::fs::write(bundle_dir.join(bundle::BUNDLE_CLIENT_FILE), "nope\n").unwrap();
1311 assert!(
1312 bundle::client_half_stale(&bundle_dir, true, true)
1313 .unwrap()
1314 .contains("modified")
1315 );
1316 assert!(
1317 bundle::client_half_stale(&bundle_dir, false, true)
1318 .unwrap()
1319 .contains("present")
1320 );
1321
1322 std::fs::write(
1323 bundle_dir.join(bundle::BUNDLE_CLIENT_FILE),
1324 skin::bundle_client_js(true),
1325 )
1326 .unwrap();
1327 assert!(bundle::client_half_stale(&bundle_dir, true, true).is_none());
1328 }
1329
1330 #[test]
1331 fn compute_status_reports_stale_config_when_client_half_drifts() {
1332 let (dir, paths) = lab_paths();
1333 let dsh_bin = fake_launcher(dir.path());
1334 let mut detection = detection_ok();
1335 detection.binary = Some(dsh_bin);
1336 detection.dsh_home = dir.path().join("dsh-home");
1337 let profile_dir = detection.dsh_home.join("profiles").join("codewhale");
1338 let runner = PluginRunner {
1339 profile_dir: profile_dir.clone(),
1340 calls: Default::default(),
1341 fail_add: false,
1342 };
1343 let id = identity(
1344 "deepseek",
1345 "deepseek-v4-flash",
1346 "https://api.deepseek.com",
1347 WireProtocol::ChatCompletions,
1348 );
1349 let plan = super::plan(&paths, &detection, &id, "web", false, true, true).unwrap();
1350 apply_plan(&paths, &detection, &plan, DshReceiptEvent::Connect).unwrap();
1351 install_bundle(&paths, &detection, &runner, &avail(), DshAppBundle::Web).unwrap();
1352
1353 let client = paths.bundle_dir.join(bundle::BUNDLE_CLIENT_FILE);
1354 std::fs::remove_file(&client).unwrap();
1355 let report = compute_status(&paths, detection.clone(), Ok(id.clone()), false, avail()).unwrap();
1356 assert!(
1357 matches!(report.state, DshIntegrationState::StaleConfig { ref reason, .. } if reason.contains("lib/client.js") && reason.contains("missing")),
1358 "{:?}",
1359 report.state
1360 );
1361
1362 let plan_on = super::plan(&paths, &detection, &id, "web", false, true, true).unwrap();
1363 apply_plan(&paths, &detection, &plan_on, DshReceiptEvent::Update).unwrap();
1364 let report = compute_status(&paths, detection.clone(), Ok(id.clone()), false, avail()).unwrap();
1365 assert!(
1366 matches!(report.state, DshIntegrationState::Connected { .. }),
1367 "{:?}",
1368 report.state
1369 );
1370
1371 std::fs::write(&client, "/* tampered */\n").unwrap();
1372 let report = compute_status(&paths, detection.clone(), Ok(id.clone()), false, avail()).unwrap();
1373 assert!(
1374 matches!(report.state, DshIntegrationState::StaleConfig { ref reason, .. } if reason.contains("modified")),
1375 "{:?}",
1376 report.state
1377 );
1378
1379 let plan_off = super::plan(&paths, &detection, &id, "web", false, false, true).unwrap();
1380 apply_plan(&paths, &detection, &plan_off, DshReceiptEvent::Update).unwrap();
1381 assert!(!client.exists());
1382 let report = compute_status(&paths, detection.clone(), Ok(id.clone()), false, avail()).unwrap();
1383 assert!(
1384 matches!(report.state, DshIntegrationState::Connected { .. }),
1385 "{:?}",
1386 report.state
1387 );
1388
1389 std::fs::create_dir_all(client.parent().unwrap()).unwrap();
1390 std::fs::write(&client, skin::bundle_client_js(true)).unwrap();
1391 let report = compute_status(&paths, detection, Ok(id), false, avail()).unwrap();
1392 assert!(
1393 matches!(report.state, DshIntegrationState::StaleConfig { ref reason, .. } if reason.contains("present") && reason.contains("disabled")),
1394 "{:?}",
1395 report.state
1396 );
1397 }
1398
1399 #[test]
1400 fn ocean_scene_fragment_mounts_a_canvas_and_honours_the_guards() {
1401 let js = scene::bundle_scene_js();
1402 assert!(js.contains("function createOcean(palette)"));
1403 assert!(
1404 !js.contains("\nexport "),
1405 "plain script: spliced into client.js"
1406 );
1407 assert!(
1408 !js.contains("\nimport "),
1409 "plain script: spliced into client.js"
1410 );
1411 // mount: fixed full-viewport canvas below the app root, no hit-testing
1412 assert!(js.contains("document.createElement(\"canvas\")"));
1413 assert!(js.contains("position:fixed;inset:0"));
1414 assert!(js.contains("z-index:-1"));
1415 assert!(js.contains("pointer-events:none"));
1416 assert!(js.contains("data-codewhale-ocean"));
1417 // motion guards
1418 assert!(js.contains("prefers-reduced-motion: reduce"));
1419 assert!(js.contains("requestAnimationFrame"));
1420 assert!(js.contains("visibilitychange"));
1421 assert!(js.contains("devicePixelRatio"));
1422 // off switch
1423 assert!(js.contains(scene::OCEAN_STORAGE_KEY));
1424 assert!(js.contains(scene::OCEAN_OFF_CLASS));
1425 assert!(js.contains(&format!("window.{}", scene::OCEAN_WINDOW_HANDLE)));
1426 // the cast
1427 assert!(js.contains("traceWhale"));
1428 assert!(js.contains("><>"));
1429 assert!(js.contains("><o>"));
1430 assert!(js.contains("drawBubbles"));
1431 assert!(js.contains("drawSpout"));
1432 assert!(!js.contains("eye"), "silhouette only, no eye dot");
1433 assert_eq!(scene::scene_sha256().len(), 64);
1434 }
1435
1436 #[test]
1437 fn brand_fragment_is_explicit_responsive_and_slot_safe() {
1438 let js = super::brand::bundle_brand_js();
1439 // The brand mark is the Codewhale whale silhouette on the deep-blue tile
1440 // (the same gradient and traced path as web/app/icon.svg), never an emoji.
1441 assert!(
1442 js.contains("viewBox: \"0 0 1254 1254\""),
1443 "whale mark viewBox"
1444 );
1445 assert!(js.contains("stopColor: \"#1D408A\""), "tile gradient start");
1446 assert!(js.contains("stopColor: \"#052366\""), "tile gradient end");
1447 assert!(js.contains("fill: \"#ffffff\""), "white whale silhouette");
1448 assert!(!js.contains("🐋"), "no whale emoji");
1449 assert!(js.contains("function CodewhaleBrand()"));
1450 assert!(js.contains("codewhale-brand-lockup"));
1451 assert!(js.contains("WHALE BROTHERS"));
1452 assert!(js.contains("CODEWHALE"));
1453 assert!(js.contains("DEEPSEEK HARNESS"));
1454 assert!(js.contains("pointer-events:none"));
1455 assert!(js.contains("@media(max-width:759px)"));
1456 assert!(js.contains("React.createElement"));
1457 assert!(!js.contains("document.body"));
1458 assert!(!js.contains("document.createElement"));
1459 assert!(!js.contains("window.addEventListener"));
1460 assert!(!js.contains("window.removeEventListener"));
1461 assert!(!js.contains("window.__codewhaleBrand"));
1462 assert_eq!(super::brand::brand_sha256().len(), 64);
1463 }
1464
1465 #[test]
1466 fn ocean_palette_and_veil_come_from_the_skin_palette() {
1467 let palette = scene::ocean_palette();
1468 for scheme in ["light", "dark"] {
1469 let p = &palette[scheme];
1470 for key in ["base", "accent", "ink", "dim"] {
1471 assert!(p[key].starts_with('#'), "{scheme}.{key} = {}", p[key]);
1472 }
1473 }
1474 assert_ne!(palette["light"]["base"], palette["dark"]["base"]);
1475 let tokens = skin::skin_tokens();
1476 assert_eq!(palette["light"]["base"], tokens["--dsw-alias-bg-base"].0);
1477 assert_eq!(palette["dark"]["base"], tokens["--dsw-alias-bg-base"].1);
1478
1479 let veil = scene::ocean_veil_tokens();
1480 let base = &veil["--dsw-alias-bg-base"];
1481 assert!(base.light.starts_with("rgba(") && base.light.ends_with(",0.42)"));
1482 assert!(base.dark.starts_with("rgba(") && base.dark.ends_with(",0.42)"));
1483 assert!(
1484 veil["--dsw-specific-sidebar-fill"]
1485 .light
1486 .starts_with("rgba(")
1487 );
1488 // round-trips as the same {light, dark} shape overrideTokens validates
1489 let json: serde_json::Value = serde_json::from_str(&scene::ocean_veil_json()).unwrap();
1490 assert!(json["--dsw-alias-bg-base"]["light"].is_string());
1491 assert!(json["--dsw-alias-bg-base"]["dark"].is_string());
1492 }
1493
1494 #[test]
1495 fn bundle_client_js_splices_the_ocean_only_when_enabled() {
1496 let on = skin::bundle_client_js(true);
1497 let off = skin::bundle_client_js(false);
1498 assert_ne!(on, off);
1499 assert!(on.contains("const OCEAN = true;"));
1500 assert!(on.contains("function createOcean(palette)"));
1501 assert!(on.contains("const OCEAN_VEIL = "));
1502 assert!(on.contains("const OCEAN_PALETTE = "));
1503 assert!(on.contains("ocean.start()"));
1504 assert!(on.contains("theme/change"));
1505 assert!(on.contains("Object.assign({}, TOKENS, OCEAN_VEIL)"));
1506 assert!(on.contains("prefers-reduced-motion: reduce"));
1507 assert!(on.contains(scene::OCEAN_STORAGE_KEY));
1508 // the whole fragment rides inside the factory (dsh serves only client.js)
1509 assert!(
1510 on.contains(
1511 scene::bundle_scene_js()
1512 .trim_end()
1513 .replace('\n', "\n\t\t")
1514 .as_str()
1515 )
1516 );
1517 assert!(off.contains("const OCEAN = false;"));
1518 assert!(!off.contains("traceWhale"));
1519 assert!(!off.contains("prefers-reduced-motion"));
1520 // both keep the palette override contract
1521 for js in [&on, &off] {
1522 assert!(js.contains("overrideTokens"));
1523 assert!(js.contains("exports.inject = [\"theme\", \"slots\"];"));
1524 assert!(js.contains("if (!ctx.theme || !ctx.slots) return;"));
1525 }
1526 }
1527
1528 #[test]
1529 fn bundle_manifest_records_the_ocean_decision_and_scene_sha() {
1530 let overlay = "- id: agent-default-model\n";
1531 let on = bundle::render_bundle_files("0.9.9", overlay, true, true);
1532 let by_name: std::collections::BTreeMap<&str, &str> =
1533 on.iter().map(|(n, t)| (*n, t.as_str())).collect();
1534 let pkg: serde_json::Value = serde_json::from_str(by_name["package.json"]).unwrap();
1535 assert_eq!(pkg["codewhale"]["ocean"], true);
1536 assert_eq!(
1537 pkg["codewhale"]["brand_sha256"],
1538 super::brand::brand_sha256()
1539 );
1540 assert_eq!(
1541 pkg["codewhale"]["ocean_scene_sha256"],
1542 scene::scene_sha256()
1543 );
1544 assert_eq!(
1545 by_name[bundle::BUNDLE_CLIENT_FILE],
1546 skin::bundle_client_js(true)
1547 );
1548 let names: Vec<_> = on.iter().map(|(n, _)| *n).collect();
1549 assert!(
1550 !names.iter().any(|n| n.contains("scene")),
1551 "no separate scene file: {names:?}"
1552 );
1553
1554 let off = bundle::render_bundle_files("0.9.9", overlay, true, false);
1555 let by_name: std::collections::BTreeMap<&str, &str> =
1556 off.iter().map(|(n, t)| (*n, t.as_str())).collect();
1557 let pkg: serde_json::Value = serde_json::from_str(by_name["package.json"]).unwrap();
1558 assert_eq!(pkg["codewhale"]["ocean"], false);
1559 assert!(pkg["codewhale"].get("ocean_scene_sha256").is_none());
1560 assert_eq!(
1561 by_name[bundle::BUNDLE_CLIENT_FILE],
1562 skin::bundle_client_js(false)
1563 );
1564
1565 // skin off ⇒ ocean off regardless
1566 let none = bundle::render_bundle_files("0.9.9", overlay, false, true);
1567 let pkg: serde_json::Value = serde_json::from_str(&none[0].1).unwrap();
1568 assert_eq!(pkg["codewhale"]["ocean"], false);
1569 }
1570
1571 #[test]
1572 fn client_half_stale_distinguishes_ocean_on_and_off() {
1573 let dir = tempfile::tempdir().unwrap();
1574 let bundle_dir = dir.path().join("bundle");
1575 std::fs::create_dir_all(bundle_dir.join("lib")).unwrap();
1576 std::fs::write(
1577 bundle_dir.join(bundle::BUNDLE_CLIENT_FILE),
1578 skin::bundle_client_js(true),
1579 )
1580 .unwrap();
1581 assert!(bundle::client_half_stale(&bundle_dir, true, true).is_none());
1582 assert!(
1583 bundle::client_half_stale(&bundle_dir, true, false)
1584 .unwrap()
1585 .contains("modified")
1586 );
1587 std::fs::write(
1588 bundle_dir.join(bundle::BUNDLE_CLIENT_FILE),
1589 skin::bundle_client_js(false),
1590 )
1591 .unwrap();
1592 assert!(bundle::client_half_stale(&bundle_dir, true, false).is_none());
1593 assert!(bundle::client_half_stale(&bundle_dir, true, true).is_some());
1594 }
1595
1596 #[test]
1597 fn update_with_ocean_off_rewrites_the_client_half_and_receipt() {
1598 let (dir, paths) = lab_paths();
1599 let dsh_bin = fake_launcher(dir.path());
1600 let mut detection = detection_ok();
1601 detection.binary = Some(dsh_bin);
1602 detection.dsh_home = dir.path().join("dsh-home");
1603 let runner = PluginRunner {
1604 profile_dir: detection.dsh_home.join("profiles").join("codewhale"),
1605 calls: Default::default(),
1606 fail_add: false,
1607 };
1608 let id = identity(
1609 "deepseek",
1610 "deepseek-v4-flash",
1611 "https://api.deepseek.com",
1612 WireProtocol::ChatCompletions,
1613 );
1614 let plan = super::plan(&paths, &detection, &id, "web", false, true, true).unwrap();
1615 assert!(plan.ocean);
1616 assert!(plan.disclosures.iter().any(|d| d.starts_with("Ocean:")));
1617 apply_plan(&paths, &detection, &plan, DshReceiptEvent::Connect).unwrap();
1618 install_bundle(&paths, &detection, &runner, &avail(), DshAppBundle::Web).unwrap();
1619 let client = paths.bundle_dir.join(bundle::BUNDLE_CLIENT_FILE);
1620 assert_eq!(
1621 std::fs::read_to_string(&client).unwrap(),
1622 skin::bundle_client_js(true)
1623 );
1624 let record = DshReceiptDocument::load(&paths.receipt)
1625 .unwrap()
1626 .current
1627 .unwrap();
1628 assert!(record.ocean_enabled);
1629 assert_eq!(serde_json::to_value(&record).unwrap()["ocean"], true);
1630 let report = compute_status(&paths, detection.clone(), Ok(id.clone()), false, avail()).unwrap();
1631 assert!(
1632 matches!(report.state, DshIntegrationState::Connected { .. }),
1633 "{:?}",
1634 report.state
1635 );
1636
1637 let off = super::plan(&paths, &detection, &id, "web", false, true, false).unwrap();
1638 assert!(!off.ocean);
1639 assert!(!off.disclosures.iter().any(|d| d.starts_with("Ocean:")));
1640 apply_plan(&paths, &detection, &off, DshReceiptEvent::Update).unwrap();
1641 assert_eq!(
1642 std::fs::read_to_string(&client).unwrap(),
1643 skin::bundle_client_js(false)
1644 );
1645 let record = DshReceiptDocument::load(&paths.receipt)
1646 .unwrap()
1647 .current
1648 .unwrap();
1649 assert!(record.skin_enabled);
1650 assert!(!record.ocean_enabled);
1651 let report = compute_status(&paths, detection.clone(), Ok(id.clone()), false, avail()).unwrap();
1652 assert!(
1653 matches!(report.state, DshIntegrationState::Connected { .. }),
1654 "{:?}",
1655 report.state
1656 );
1657
1658 // skin off implies ocean off in the plan
1659 let no_skin = super::plan(&paths, &detection, &id, "web", false, false, true).unwrap();
1660 assert!(!no_skin.ocean);
1661 }
1662
1663 #[test]
1664 fn receipts_written_before_the_ocean_load_with_ocean_on() {
1665 let (_dir, paths) = lab_paths();
1666 let detection = detection_ok();
1667 let id = identity(
1668 "deepseek",
1669 "deepseek-v4-flash",
1670 "https://api.deepseek.com",
1671 WireProtocol::ChatCompletions,
1672 );
1673 let plan = super::plan(&paths, &detection, &id, "web", false, true, true).unwrap();
1674 apply_plan(&paths, &detection, &plan, DshReceiptEvent::Connect).unwrap();
1675 let text = std::fs::read_to_string(&paths.receipt).unwrap();
1676 let mut json: serde_json::Value = serde_json::from_str(&text).unwrap();
1677 json["current"].as_object_mut().unwrap().remove("ocean");
1678 std::fs::write(&paths.receipt, serde_json::to_string_pretty(&json).unwrap()).unwrap();
1679 let record = DshReceiptDocument::load(&paths.receipt)
1680 .unwrap()
1681 .current
1682 .unwrap();
1683 assert!(record.ocean_enabled);
1684 }
1685
1685 lines RUST