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