| 1 | use super::*; |
| 2 | // `fs` was a cfg(test) import on the parent, and `Path` is now only used by |
| 3 | // the legacy/render seams. Both belong here. |
| 4 | use crate::config::Config; |
| 5 | use crate::tui::app::{App, TuiOptions}; |
| 6 | use codewhale_localization::Locale; |
| 7 | use std::fs; |
| 8 | use std::path::Path; |
| 9 | use tempfile::TempDir; |
| 10 | |
| 11 | #[test] |
| 12 | fn extension_owner_report_escapes_every_plugin_controlled_field() { |
| 13 | struct Presentation(Locale); |
| 14 | impl CommandPresentationContext for Presentation { |
| 15 | fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result<String, String> { |
| 16 | let id = crate::commands::contract::key_to_plugin_message_id(key).unwrap(); |
| 17 | let mut output = codewhale_localization::tr(self.0, id).to_string(); |
| 18 | for (name, value) in replacements { |
| 19 | output = output.replace(&format!("{{{name}}}"), value); |
| 20 | } |
| 21 | Ok(output) |
| 22 | } |
| 23 | } |
| 24 | let mut output = String::new(); |
| 25 | append_host_owner_report( |
| 26 | &Presentation(Locale::En), |
| 27 | &mut output, |
| 28 | &crate::extension_host::OwnerReport { |
| 29 | state: Some(crate::extension_host::registry::OwnerState::Failed( |
| 30 | "\u{1b}[31m<script>".into(), |
| 31 | )), |
| 32 | tools: vec".into()], |
| 33 | diagnostics: vec!["\n# approved\u{202e}".into()], |
| 34 | }, |
| 35 | ); |
| 36 | assert!(output.contains("Extension host:")); |
| 37 | for value in [ |
| 38 | "\u{1b}[31m<script>", |
| 39 | "[tool](https://example.invalid)", |
| 40 | "\n# approved\u{202e}", |
| 41 | ] { |
| 42 | assert!(output.contains(&escape_review_text(value))); |
| 43 | assert!(!output.contains(value)); |
| 44 | } |
| 45 | let mut localized = String::new(); |
| 46 | append_host_owner_report( |
| 47 | &Presentation(Locale::ZhHans), |
| 48 | &mut localized, |
| 49 | &crate::extension_host::OwnerReport { |
| 50 | state: None, |
| 51 | tools: vec![], |
| 52 | diagnostics: vec![], |
| 53 | }, |
| 54 | ); |
| 55 | assert_eq!( |
| 56 | localized, |
| 57 | "\n扩展宿主:\n 状态:未激活\n 活动工具(0):—" |
| 58 | ); |
| 59 | } |
| 60 | |
| 61 | #[test] |
| 62 | fn plugin_config_summary_lists_keys_never_values_and_escapes_them() { |
| 63 | let mut output = String::new(); |
| 64 | append_plugin_config_summary( |
| 65 | &mut output, |
| 66 | "greeter", |
| 67 | &Ok(vec!["greeting".to_string(), "\u{1b}[31mkey".to_string()]), |
| 68 | ); |
| 69 | assert!(output.contains("[plugins.\"greeter\".config]"), "{output}"); |
| 70 | assert!(output.contains("values not shown")); |
| 71 | assert!(output.contains("greeting")); |
| 72 | assert!(output.contains(&escape_review_text("\u{1b}[31mkey"))); |
| 73 | assert!(!output.contains('\u{1b}')); |
| 74 | |
| 75 | let mut refused = String::new(); |
| 76 | append_plugin_config_summary( |
| 77 | &mut refused, |
| 78 | "greeter", |
| 79 | &Err("config is 20000 bytes\n# approved".to_string()), |
| 80 | ); |
| 81 | assert!(refused.contains("is refused: "), "{refused}"); |
| 82 | assert!(!refused.contains("\n# approved"), "{refused}"); |
| 83 | } |
| 84 | |
| 85 | fn create_test_app(root: &Path) -> (App, TempDir) { |
| 86 | let temp = TempDir::new().expect("tempdir"); |
| 87 | let config_path = temp.path().join("config.toml"); |
| 88 | let tools_dir = root.join("tools"); |
| 89 | fs::create_dir_all(&tools_dir).unwrap(); |
| 90 | fs::write( |
| 91 | &config_path, |
| 92 | format!( |
| 93 | "[tools]\nplugin_dir = {}\n", |
| 94 | toml::Value::String(tools_dir.to_string_lossy().to_string()) |
| 95 | ), |
| 96 | ) |
| 97 | .unwrap(); |
| 98 | let options = TuiOptions { |
| 99 | config_path: Some(config_path), |
| 100 | skills_dir: temp.path().join("skills"), |
| 101 | memory_path: temp.path().join("memory.md"), |
| 102 | notes_path: temp.path().join("notes.txt"), |
| 103 | mcp_config_path: temp.path().join("mcp.json"), |
| 104 | ..crate::test_support::test_tui_options(root) |
| 105 | }; |
| 106 | let config = Config { |
| 107 | tools: Some(crate::config::ToolsConfig { |
| 108 | plugin_dir: Some(tools_dir.to_string_lossy().into_owned()), |
| 109 | ..Default::default() |
| 110 | }), |
| 111 | ..Default::default() |
| 112 | }; |
| 113 | let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv(); |
| 114 | let registry = discovery.registry_for_workspace(root); |
| 115 | let mut app = App::new_with_plugin_registry(options, &config, registry); |
| 116 | app.ui_locale = Locale::En; |
| 117 | (app, temp) |
| 118 | } |
| 119 | |
| 120 | fn write_bundle(root: &Path) { |
| 121 | let bundle = root.join(".codewhale/plugins/demo"); |
| 122 | fs::create_dir_all(bundle.join("skills/hello")).unwrap(); |
| 123 | fs::write( |
| 124 | bundle.join("plugin.toml"), |
| 125 | "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\ndescription = \"Import spreadsheet data safely\"\n[skills]\npath = \"skills\"\n", |
| 126 | ) |
| 127 | .unwrap(); |
| 128 | fs::write( |
| 129 | bundle.join("skills/hello/SKILL.md"), |
| 130 | "---\nname: hello\ndescription: hello\n---\nbody\n", |
| 131 | ) |
| 132 | .unwrap(); |
| 133 | } |
| 134 | |
| 135 | fn write_mcp_review_bundle(root: &Path) { |
| 136 | let bundle = root.join(".codewhale/plugins/review-mcp"); |
| 137 | fs::create_dir_all(&bundle).unwrap(); |
| 138 | fs::write(bundle.join("server.js"), "// reviewed entrypoint\n").unwrap(); |
| 139 | fs::write( |
| 140 | bundle.join("plugin.toml"), |
| 141 | r#"schema_version = 1 |
| 142 | [plugin] |
| 143 | name = "review-mcp" |
| 144 | version = "1.0.0" |
| 145 | |
| 146 | [mcp_servers.local] |
| 147 | command = "node" |
| 148 | args = ["server.js", "--mode=worker", "-e", "console.log('ready')"] |
| 149 | |
| 150 | [mcp_servers.local.env] |
| 151 | PLUGIN_TOKEN = "${PLUGIN_TOKEN_SOURCE}" |
| 152 | |
| 153 | [mcp_servers.remote] |
| 154 | url = "https://example.invalid/mcp" |
| 155 | bearer_token_env_var = "REMOTE_TOKEN" |
| 156 | |
| 157 | [mcp_servers.remote.env_headers] |
| 158 | X_Api_Key = "REMOTE_API_KEY" |
| 159 | |
| 160 | [capabilities] |
| 161 | network_hosts = ["example.invalid"] |
| 162 | "#, |
| 163 | ) |
| 164 | .unwrap(); |
| 165 | } |
| 166 | |
| 167 | #[test] |
| 168 | fn bare_plugin_command_opens_unified_extensions_modal() { |
| 169 | let _lock = crate::test_support::lock_test_env(); |
| 170 | let root = TempDir::new().unwrap(); |
| 171 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 172 | let (mut app, _temp) = create_test_app(root.path()); |
| 173 | |
| 174 | let result = plugins_with_kimi_home_override(&mut app, None, None); |
| 175 | |
| 176 | assert!(matches!( |
| 177 | result.action, |
| 178 | Some(AppAction::OpenExtensions { |
| 179 | tab: crate::tui::views::extensions::ExtensionsTab::Plugins |
| 180 | }) |
| 181 | )); |
| 182 | assert!(result.message.is_none()); |
| 183 | } |
| 184 | |
| 185 | #[test] |
| 186 | fn list_show_validate_are_read_only_and_label_legacy_tools() { |
| 187 | let _lock = crate::test_support::lock_test_env(); |
| 188 | let root = TempDir::new().unwrap(); |
| 189 | let codewhale_home = root.path().join("home"); |
| 190 | // A configured user has a home; that is the state in which the built-in |
| 191 | // bundle is materialized and listed. |
| 192 | fs::create_dir_all(&codewhale_home).unwrap(); |
| 193 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 194 | write_bundle(root.path()); |
| 195 | let (mut app, _temp) = create_test_app(root.path()); |
| 196 | fs::write( |
| 197 | root.path().join("tools/greet.sh"), |
| 198 | "# name: greet\n# description: hello\n", |
| 199 | ) |
| 200 | .unwrap(); |
| 201 | // The app already resolved the legacy tools path during startup. |
| 202 | // Read-only plugin commands must not reopen a credential-bearing |
| 203 | // config file merely to inventory those tools. |
| 204 | fs::write( |
| 205 | app.config_path.as_ref().unwrap(), |
| 206 | "api_key = [\"must-not-be-re-read\"\n", |
| 207 | ) |
| 208 | .unwrap(); |
| 209 | let state_path = codewhale_home.join("plugins/state.json"); |
| 210 | |
| 211 | for arg in [Some("list"), Some("show demo"), Some("validate")] { |
| 212 | let result = plugins_with_kimi_home_override(&mut app, arg, None); |
| 213 | assert!(!result.is_error, "{:?}", result.message); |
| 214 | assert!(!state_path.exists(), "read-only command wrote plugin state"); |
| 215 | } |
| 216 | // PR #5865's call shape, with main's assertions: the workspace bundle plus |
| 217 | // the built-in computer-use bundle, which every binary now carries and |
| 218 | // which lists disabled until it is reviewed. |
| 219 | let list = plugins_with_kimi_home_override(&mut app, Some("list"), None) |
| 220 | .message |
| 221 | .unwrap(); |
| 222 | assert!(list.contains("Plugin bundles (2)"), "{list}"); |
| 223 | // The renderer escapes markdown, so the hyphen arrives backslashed. |
| 224 | assert!(list.contains(r"computer\-use"), "{list}"); |
| 225 | assert!(list.contains("builtin · not-reviewed"), "{list}"); |
| 226 | assert!(list.contains("disabled")); |
| 227 | assert!(list.contains("Legacy plugin tools (1)")); |
| 228 | } |
| 229 | |
| 230 | #[test] |
| 231 | fn list_preserves_the_one_shot_on_disk_reload_nudge() { |
| 232 | let _lock = crate::test_support::lock_test_env(); |
| 233 | let root = TempDir::new().unwrap(); |
| 234 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 235 | let (mut app, _temp) = create_test_app(root.path()); |
| 236 | |
| 237 | // Mutate the on-disk catalog after discovery. Listing must report the |
| 238 | // current-main nudge without rediscovering or changing trust state. |
| 239 | write_bundle(root.path()); |
| 240 | let first = plugins_with_kimi_home_override(&mut app, Some("list"), None) |
| 241 | .message |
| 242 | .unwrap(); |
| 243 | assert!( |
| 244 | first.contains(crate::plugins::PLUGIN_RELOAD_NUDGE), |
| 245 | "{first}" |
| 246 | ); |
| 247 | |
| 248 | let second = plugins_with_kimi_home_override(&mut app, Some("list"), None) |
| 249 | .message |
| 250 | .unwrap(); |
| 251 | assert!( |
| 252 | !second.contains(crate::plugins::PLUGIN_RELOAD_NUDGE), |
| 253 | "nudge must appear once per catalog stamp: {second}" |
| 254 | ); |
| 255 | } |
| 256 | |
| 257 | #[test] |
| 258 | fn suggest_ranks_installed_plugins_without_trusting_or_enabling_them() { |
| 259 | let _lock = crate::test_support::lock_test_env(); |
| 260 | let root = TempDir::new().unwrap(); |
| 261 | let codewhale_home = root.path().join("home"); |
| 262 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 263 | write_bundle(root.path()); |
| 264 | let (mut app, _temp) = create_test_app(root.path()); |
| 265 | |
| 266 | for arg in ["suggest", "suggest go"] { |
| 267 | let result = plugins_with_kimi_home_override(&mut app, Some(arg), None); |
| 268 | assert!( |
| 269 | result.is_error, |
| 270 | "expected usage error for {arg}: {result:?}" |
| 271 | ); |
| 272 | } |
| 273 | |
| 274 | let result = |
| 275 | plugins_with_kimi_home_override(&mut app, Some("suggest spreadsheet import"), None); |
| 276 | assert!(!result.is_error, "{result:?}"); |
| 277 | let message = result.message.expect("suggestion message"); |
| 278 | assert!(message.contains("Suggested plugins"), "{message}"); |
| 279 | assert!(message.contains("demo — disabled"), "{message}"); |
| 280 | assert!(message.contains("Why:"), "{message}"); |
| 281 | assert!(message.contains("/plugin trust demo"), "{message}"); |
| 282 | assert!( |
| 283 | message.contains("spreadsheet") || message.contains("import"), |
| 284 | "{message}" |
| 285 | ); |
| 286 | assert!(message.contains("Nothing was installed, trusted, or enabled.")); |
| 287 | assert!(!codewhale_home.join("plugins/state.json").exists()); |
| 288 | let plugin = app.plugin_registry.get("demo").expect("demo plugin"); |
| 289 | assert!(!plugin.enabled && !plugin.trusted()); |
| 290 | } |
| 291 | |
| 292 | #[test] |
| 293 | fn suggest_matches_manifest_keywords_for_a_named_integration() { |
| 294 | let _lock = crate::test_support::lock_test_env(); |
| 295 | let root = TempDir::new().unwrap(); |
| 296 | let codewhale_home = root.path().join("home"); |
| 297 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 298 | let bundle = root.path().join(".codewhale/plugins/supabase"); |
| 299 | fs::create_dir_all(&bundle).unwrap(); |
| 300 | fs::write( |
| 301 | bundle.join("plugin.toml"), |
| 302 | "schema_version = 1\n[plugin]\nname = \"supabase\"\nversion = \"1.0.0\"\ndescription = \"Hosted Postgres and auth\"\nkeywords = [\"supabase\", \"postgres\"]\n", |
| 303 | ) |
| 304 | .unwrap(); |
| 305 | let (mut app, _temp) = create_test_app(root.path()); |
| 306 | |
| 307 | let result = plugins_with_kimi_home_override(&mut app, Some("suggest add supabase auth"), None); |
| 308 | assert!(!result.is_error, "{result:?}"); |
| 309 | let message = result.message.expect("suggestion message"); |
| 310 | assert!(message.contains("supabase"), "{message}"); |
| 311 | assert!(message.contains("/plugin trust supabase"), "{message}"); |
| 312 | assert!(message.contains("Nothing was installed, trusted, or enabled.")); |
| 313 | } |
| 314 | |
| 315 | #[test] |
| 316 | fn trust_requires_content_and_capability_bound_review_token() { |
| 317 | let _lock = crate::test_support::lock_test_env(); |
| 318 | let root = TempDir::new().unwrap(); |
| 319 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 320 | write_bundle(root.path()); |
| 321 | let (mut app, _temp) = create_test_app(root.path()); |
| 322 | let enable_review = plugins_with_kimi_home_override(&mut app, Some("enable demo"), None); |
| 323 | assert!(!enable_review.is_error); |
| 324 | assert!( |
| 325 | enable_review |
| 326 | .message |
| 327 | .as_deref() |
| 328 | .is_some_and(|message| message.contains("/plugin trust demo ")) |
| 329 | ); |
| 330 | assert!(!app.plugin_registry.get("demo").unwrap().trusted()); |
| 331 | |
| 332 | let review_result = plugins_with_kimi_home_override(&mut app, Some("trust demo"), None); |
| 333 | let Some(AppAction::OpenCommandReview { |
| 334 | content, command, .. |
| 335 | }) = review_result.action |
| 336 | else { |
| 337 | panic!("trust opens a confirmation control"); |
| 338 | }; |
| 339 | assert!( |
| 340 | !content.contains("/plugin trust demo "), |
| 341 | "the hash belongs to the control" |
| 342 | ); |
| 343 | let review = review_result.message.unwrap(); |
| 344 | let confirmation = review |
| 345 | .lines() |
| 346 | .find(|line| line.starts_with("/plugin trust demo ")) |
| 347 | .unwrap(); |
| 348 | assert_eq!(confirmation, command); |
| 349 | let token = confirmation |
| 350 | .split_whitespace() |
| 351 | .last() |
| 352 | .expect("review confirmation token"); |
| 353 | let (content_digest, capability_digest) = token |
| 354 | .split_once('.') |
| 355 | .expect("content and capability digests"); |
| 356 | assert_eq!(content_digest.len(), 64); |
| 357 | assert_eq!(capability_digest.len(), 64); |
| 358 | assert!(content_digest.bytes().all(|byte| byte.is_ascii_hexdigit())); |
| 359 | assert!( |
| 360 | capability_digest |
| 361 | .bytes() |
| 362 | .all(|byte| byte.is_ascii_hexdigit()) |
| 363 | ); |
| 364 | assert!(!app.plugin_registry.get("demo").unwrap().trusted()); |
| 365 | |
| 366 | assert!(plugins_with_kimi_home_override(&mut app, Some("trust demo wrong"), None).is_error); |
| 367 | let shortened = format!( |
| 368 | "trust demo {}.{}", |
| 369 | &content_digest[..12], |
| 370 | &capability_digest[..12] |
| 371 | ); |
| 372 | assert!( |
| 373 | plugins_with_kimi_home_override(&mut app, Some(&shortened), None).is_error, |
| 374 | "the legacy 48-bit content prefix must not authorize trust" |
| 375 | ); |
| 376 | let arg = confirmation.trim_start_matches("/plugin "); |
| 377 | assert!(!plugins_with_kimi_home_override(&mut app, Some(arg), None).is_error); |
| 378 | assert!(!plugins_with_kimi_home_override(&mut app, Some("enable demo"), None).is_error); |
| 379 | assert!(app.plugin_registry.is_active("demo")); |
| 380 | assert!(!plugins_with_kimi_home_override(&mut app, Some("disable demo"), None).is_error); |
| 381 | assert!(!app.plugin_registry.is_active("demo")); |
| 382 | } |
| 383 | |
| 384 | fn write_mixed_bundle(root: &Path) { |
| 385 | let bundle = root.join(".codewhale/plugins/mixed"); |
| 386 | fs::create_dir_all(bundle.join("skills/hello")).unwrap(); |
| 387 | fs::create_dir_all(bundle.join("commands")).unwrap(); |
| 388 | fs::create_dir_all(bundle.join("hooks")).unwrap(); |
| 389 | fs::create_dir_all(bundle.join("lsp")).unwrap(); |
| 390 | fs::write( |
| 391 | bundle.join("plugin.toml"), |
| 392 | "schema_version = 1\n[plugin]\nname = \"mixed\"\nversion = \"1.0.0\"\n[skills]\npath = \"skills\"\n[commands]\npath = \"commands\"\n[hooks]\npath = \"hooks\"\n[lsp]\npath = \"lsp\"\n", |
| 393 | ) |
| 394 | .unwrap(); |
| 395 | fs::write( |
| 396 | bundle.join("skills/hello/SKILL.md"), |
| 397 | "---\nname: hello\ndescription: hello\n---\nbody\n", |
| 398 | ) |
| 399 | .unwrap(); |
| 400 | } |
| 401 | |
| 402 | #[test] |
| 403 | fn mixed_bundle_review_and_enable_keep_supported_components_active() { |
| 404 | let _lock = crate::test_support::lock_test_env(); |
| 405 | let root = TempDir::new().unwrap(); |
| 406 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 407 | write_mixed_bundle(root.path()); |
| 408 | let (mut app, _temp) = create_test_app(root.path()); |
| 409 | |
| 410 | let list = plugins_with_kimi_home_override(&mut app, Some("list"), None) |
| 411 | .message |
| 412 | .unwrap(); |
| 413 | assert!(list.contains("compatibility=partial"), "{list}"); |
| 414 | assert!(list.contains("commands=1"), "{list}"); |
| 415 | assert!(list.contains("hooks=1"), "{list}"); |
| 416 | |
| 417 | let show = plugins_with_kimi_home_override(&mut app, Some("show mixed"), None) |
| 418 | .message |
| 419 | .unwrap(); |
| 420 | assert!(show.contains("Compatibility: partial"), "{show}"); |
| 421 | assert!(show.contains("Inactive components: [lsp]"), "{show}"); |
| 422 | assert!(show.contains("Active components: [none]"), "{show}"); |
| 423 | |
| 424 | let review = plugins_with_kimi_home_override(&mut app, Some("trust mixed"), None) |
| 425 | .message |
| 426 | .unwrap(); |
| 427 | let confirmation = review |
| 428 | .lines() |
| 429 | .find(|line| line.starts_with("/plugin trust mixed ")) |
| 430 | .unwrap(); |
| 431 | let arg = confirmation.trim_start_matches("/plugin "); |
| 432 | assert!(!plugins_with_kimi_home_override(&mut app, Some(arg), None).is_error); |
| 433 | let enabled = plugins_with_kimi_home_override(&mut app, Some("enable mixed"), None); |
| 434 | assert!(!enabled.is_error, "{:?}", enabled.message); |
| 435 | let message = enabled.message.unwrap(); |
| 436 | assert!(message.contains("Compatibility: partial"), "{message}"); |
| 437 | assert!(message.contains("inactive: lsp"), "{message}"); |
| 438 | assert!(app.plugin_registry.is_active("mixed")); |
| 439 | assert_eq!( |
| 440 | app.plugin_registry |
| 441 | .get("mixed") |
| 442 | .unwrap() |
| 443 | .compatibility() |
| 444 | .as_str(), |
| 445 | "partial" |
| 446 | ); |
| 447 | |
| 448 | let show = plugins_with_kimi_home_override(&mut app, Some("show mixed"), None) |
| 449 | .message |
| 450 | .unwrap(); |
| 451 | assert!(show.contains("State: active"), "{show}"); |
| 452 | assert!(show.contains("Inactive components: [lsp]"), "{show}"); |
| 453 | assert!( |
| 454 | show.contains("Active components: [skills, commands, hooks]"), |
| 455 | "{show}" |
| 456 | ); |
| 457 | assert!(show.contains("Qualified skills: [mixed:hello]"), "{show}"); |
| 458 | } |
| 459 | |
| 460 | #[test] |
| 461 | fn mcp_review_discloses_host_authority_and_names_without_secret_values() { |
| 462 | let _lock = crate::test_support::lock_test_env(); |
| 463 | let root = TempDir::new().unwrap(); |
| 464 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 465 | write_mcp_review_bundle(root.path()); |
| 466 | let (mut app, _temp) = create_test_app(root.path()); |
| 467 | let review = plugins_with_kimi_home_override(&mut app, Some("trust review-mcp"), None) |
| 468 | .message |
| 469 | .expect("review output"); |
| 470 | assert!(review.contains("mcp=2 (stdio=1 remote=1)")); |
| 471 | assert!(review.contains("host-user filesystem/network authority")); |
| 472 | assert!(review.contains("PLUGIN\\_TOKEN <- PLUGIN\\_TOKEN\\_SOURCE")); |
| 473 | assert!(review.contains("X\\_Api\\_Key <- REMOTE\\_API\\_KEY")); |
| 474 | assert!(review.contains("bearer_env=REMOTE\\_TOKEN")); |
| 475 | assert!(review.contains("redirects=same-origin-only")); |
| 476 | assert!(review.contains("Qualified skills: [none]")); |
| 477 | assert!(review.contains("#2 value=\"--mode=worker\"")); |
| 478 | assert!(review.contains("#3 value=\"-e\"")); |
| 479 | assert!(review.contains("#4 value=\"console.log('ready')\"")); |
| 480 | assert!(review.contains("oauth=disabled")); |
| 481 | } |
| 482 | |
| 483 | #[test] |
| 484 | fn legacy_tool_detail_remains_available_under_tools_namespace() { |
| 485 | let _lock = crate::test_support::lock_test_env(); |
| 486 | let root = TempDir::new().unwrap(); |
| 487 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 488 | let (mut app, _temp) = create_test_app(root.path()); |
| 489 | fs::write( |
| 490 | root.path().join("tools/greet.sh"), |
| 491 | "# name: greet\n# description: Say hello\n# approval: required\n", |
| 492 | ) |
| 493 | .unwrap(); |
| 494 | let result = plugins_with_kimi_home_override(&mut app, Some("tools greet"), None); |
| 495 | assert!(!result.is_error); |
| 496 | let message = result.message.unwrap(); |
| 497 | assert!(message.contains("Say hello")); |
| 498 | assert!(message.contains("required")); |
| 499 | } |
| 500 | |
| 501 | /// D4: `/plugin tools` names a script whose `approval: auto` was ignored and |
| 502 | /// shows the approval it actually runs with. |
| 503 | #[test] |
| 504 | fn legacy_tools_report_ignored_auto_approval() { |
| 505 | let _lock = crate::test_support::lock_test_env(); |
| 506 | let root = TempDir::new().unwrap(); |
| 507 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 508 | let (mut app, _temp) = create_test_app(root.path()); |
| 509 | fs::write( |
| 510 | root.path().join("tools/greet.sh"), |
| 511 | "# name: greet\n# description: Say hello\n# approval: auto\n", |
| 512 | ) |
| 513 | .unwrap(); |
| 514 | fs::write( |
| 515 | root.path().join("tools/audit.sh"), |
| 516 | "# name: audit\n# description: Audit\n# approval: required\n", |
| 517 | ) |
| 518 | .unwrap(); |
| 519 | // The renderer escapes Markdown in plugin-controlled text. |
| 520 | let warning = "[script_tool_auto_approval_ignored]: script tool 'greet': \\`approval: auto\\` is no longer supported for script tools"; |
| 521 | |
| 522 | let list = plugins_with_kimi_home_override(&mut app, Some("tools"), None) |
| 523 | .message |
| 524 | .unwrap(); |
| 525 | assert!(list.contains(warning), "{list}"); |
| 526 | assert!(!list.contains("script tool 'audit'"), "{list}"); |
| 527 | |
| 528 | let detail = plugins_with_kimi_home_override(&mut app, Some("tools greet"), None) |
| 529 | .message |
| 530 | .unwrap(); |
| 531 | assert!(detail.contains("suggest"), "{detail}"); |
| 532 | assert!(detail.contains(warning), "{detail}"); |
| 533 | let other = plugins_with_kimi_home_override(&mut app, Some("tools audit"), None) |
| 534 | .message |
| 535 | .unwrap(); |
| 536 | assert!( |
| 537 | !other.contains("script_tool_auto_approval_ignored"), |
| 538 | "{other}" |
| 539 | ); |
| 540 | } |
| 541 | |
| 542 | #[test] |
| 543 | fn install_update_uninstall_verbs_validate_arguments() { |
| 544 | let _lock = crate::test_support::lock_test_env(); |
| 545 | let root = TempDir::new().unwrap(); |
| 546 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 547 | let (mut app, _temp) = create_test_app(root.path()); |
| 548 | for arg in ["install", "update", "uninstall"] { |
| 549 | let result = plugins_with_kimi_home_override(&mut app, Some(arg), None); |
| 550 | assert!(result.is_error, "bare `{arg}` must print usage"); |
| 551 | } |
| 552 | let invalid = plugins_with_kimi_home_override(&mut app, Some("install github:"), None); |
| 553 | assert!(invalid.is_error); |
| 554 | assert!( |
| 555 | invalid |
| 556 | .message |
| 557 | .unwrap() |
| 558 | .contains("Invalid plugin install source"), |
| 559 | "invalid specs must be rejected before any network or disk access" |
| 560 | ); |
| 561 | } |
| 562 | |
| 563 | #[test] |
| 564 | fn install_update_uninstall_verbs_drive_the_guided_trust_flow() { |
| 565 | let _lock = crate::test_support::lock_test_env(); |
| 566 | let root = TempDir::new().unwrap(); |
| 567 | let codewhale_home = root.path().join("home"); |
| 568 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 569 | |
| 570 | let source = root.path().join("source/installed-demo"); |
| 571 | fs::create_dir_all(&source).unwrap(); |
| 572 | fs::write( |
| 573 | source.join("plugin.toml"), |
| 574 | "schema_version = 1\n[plugin]\nname = \"installed-demo\"\nversion = \"1.0.0\"\n", |
| 575 | ) |
| 576 | .unwrap(); |
| 577 | |
| 578 | let (mut app, _temp) = create_test_app(root.path()); |
| 579 | let runtime = tokio::runtime::Builder::new_multi_thread() |
| 580 | .worker_threads(2) |
| 581 | .enable_all() |
| 582 | .build() |
| 583 | .unwrap(); |
| 584 | runtime.block_on(async { |
| 585 | let installed = plugins_with_kimi_home_override( |
| 586 | &mut app, |
| 587 | Some(&format!("install {}", source.display())), |
| 588 | None, |
| 589 | ); |
| 590 | assert!(!installed.is_error, "{:?}", installed.message); |
| 591 | let message = installed.message.unwrap(); |
| 592 | assert!(message.contains("disabled and untrusted"), "{message}"); |
| 593 | let confirmation = message |
| 594 | .lines() |
| 595 | .find(|line| line.starts_with("/plugin trust installed-demo ")) |
| 596 | .expect("install must route into the trust review") |
| 597 | .to_string(); |
| 598 | let plugin = app.plugin_registry.get("installed-demo").unwrap(); |
| 599 | assert!(!plugin.enabled && !plugin.trusted()); |
| 600 | assert!( |
| 601 | codewhale_home |
| 602 | .join("plugins/installed-demo/.installed-from") |
| 603 | .exists() |
| 604 | ); |
| 605 | |
| 606 | // Local-path installs cannot be updated from the network. |
| 607 | let update = plugins_with_kimi_home_override(&mut app, Some("update installed-demo"), None); |
| 608 | assert!(update.is_error); |
| 609 | assert!(update.message.unwrap().contains("local path")); |
| 610 | |
| 611 | let arg = confirmation.trim_start_matches("/plugin ").to_string(); |
| 612 | assert!(!plugins_with_kimi_home_override(&mut app, Some(&arg), None).is_error); |
| 613 | assert!( |
| 614 | !plugins_with_kimi_home_override(&mut app, Some("enable installed-demo"), None) |
| 615 | .is_error |
| 616 | ); |
| 617 | assert!(app.plugin_registry.is_active("installed-demo")); |
| 618 | |
| 619 | // Uninstall requires disabled, then removes bits and prunes state. |
| 620 | let refused = |
| 621 | plugins_with_kimi_home_override(&mut app, Some("uninstall installed-demo"), None); |
| 622 | assert!(refused.is_error); |
| 623 | assert!(codewhale_home.join("plugins/installed-demo").exists()); |
| 624 | assert!( |
| 625 | !plugins_with_kimi_home_override(&mut app, Some("disable installed-demo"), None) |
| 626 | .is_error |
| 627 | ); |
| 628 | let removed = |
| 629 | plugins_with_kimi_home_override(&mut app, Some("uninstall installed-demo"), None); |
| 630 | assert!(!removed.is_error, "{:?}", removed.message); |
| 631 | assert!(!codewhale_home.join("plugins/installed-demo").exists()); |
| 632 | assert!(app.plugin_registry.get("installed-demo").is_none()); |
| 633 | let raw = fs::read_to_string(codewhale_home.join("plugins/state.json")).unwrap(); |
| 634 | let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); |
| 635 | assert!( |
| 636 | parsed["plugins"].as_object().unwrap().is_empty(), |
| 637 | "uninstall must prune the state entry: {raw}" |
| 638 | ); |
| 639 | }); |
| 640 | } |
| 641 | |
| 642 | #[test] |
| 643 | fn dsh_import_reviews_without_installing_then_installs_the_exact_bundle() { |
| 644 | let _lock = crate::test_support::lock_test_env(); |
| 645 | let root = TempDir::new().unwrap(); |
| 646 | let codewhale_home = root.path().join("codewhale-home"); |
| 647 | let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 648 | let package = root.path().join("dsh-package"); |
| 649 | fs::create_dir_all(package.join("pack-skills/guide")).unwrap(); |
| 650 | fs::write( |
| 651 | package.join("package.json"), |
| 652 | r#"{"name": "@demo/docs-dsh", "version": "2.0.0", "dsh": {"bundle": {"patch": "./cordis.patch.yml"}}}"#, |
| 653 | ) |
| 654 | .unwrap(); |
| 655 | fs::write( |
| 656 | package.join("cordis.patch.yml"), |
| 657 | "- insert:\n - id: docs\n name: '@deepseek-ai/dsh-mcp-client'\n config: {serverName: docs, transport: streamable-http, url: 'https://docs.example.invalid/mcp'}\n - id: skills\n name: '@deepseek-ai/dsh-skill-filesystem'\n config: {customSkillDirs: [pack-skills]}\n - id: theme\n name: '@deepseek-ai/dsh-client-ui-theme'\n", |
| 658 | ) |
| 659 | .unwrap(); |
| 660 | fs::write( |
| 661 | package.join("pack-skills/guide/SKILL.md"), |
| 662 | "---\nname: guide\ndescription: Bundled guide\n---\nBody.\n", |
| 663 | ) |
| 664 | .unwrap(); |
| 665 | |
| 666 | let (mut app, _temp) = create_test_app(root.path()); |
| 667 | let help = plugins_with_kimi_home_override(&mut app, Some("help"), None) |
| 668 | .message |
| 669 | .unwrap(); |
| 670 | assert!(help.contains("/plugin import dsh <package-dir>"), "{help}"); |
| 671 | let review = plugins_with_kimi_home_override( |
| 672 | &mut app, |
| 673 | Some(&format!("import dsh {}", package.display())), |
| 674 | None, |
| 675 | ); |
| 676 | assert!(!review.is_error, "{:?}", review.message); |
| 677 | let message = review.message.unwrap(); |
| 678 | for fact in [ |
| 679 | "@demo/docs-dsh@2.0.0", |
| 680 | "plugin 'docs-dsh'", |
| 681 | "Skills: guide", |
| 682 | "Remote MCP servers: docs", |
| 683 | "Network hosts it will request: docs.example.invalid", |
| 684 | "theme", |
| 685 | "Nothing was installed", |
| 686 | ] { |
| 687 | // Untrusted package text is rendered with review escaping. |
| 688 | assert!( |
| 689 | message.replace('\\', "").contains(fact), |
| 690 | "{fact}: {message}" |
| 691 | ); |
| 692 | } |
| 693 | let approval = message |
| 694 | .lines() |
| 695 | .find_map(|line| line.trim().strip_prefix("/plugin ")) |
| 696 | .expect("review renders an exact approval command") |
| 697 | .to_string(); |
| 698 | assert!(!codewhale_home.join("plugins/docs-dsh").exists()); |
| 699 | |
| 700 | let runtime = tokio::runtime::Builder::new_multi_thread() |
| 701 | .worker_threads(2) |
| 702 | .enable_all() |
| 703 | .build() |
| 704 | .unwrap(); |
| 705 | runtime.block_on(async { |
| 706 | let installed = plugins_with_kimi_home_override(&mut app, Some(&approval), None); |
| 707 | assert!(!installed.is_error, "{:?}", installed.message); |
| 708 | assert!( |
| 709 | installed |
| 710 | .message |
| 711 | .as_deref() |
| 712 | .is_some_and(|m| m.contains("disabled and untrusted")) |
| 713 | ); |
| 714 | }); |
| 715 | let plugin = app.plugin_registry.get("docs-dsh").unwrap(); |
| 716 | assert!(!plugin.enabled && !plugin.trusted()); |
| 717 | assert!( |
| 718 | codewhale_home |
| 719 | .join("plugins/docs-dsh/CONVERSION.md") |
| 720 | .is_file() |
| 721 | ); |
| 722 | } |
| 723 | |
| 724 | #[test] |
| 725 | fn kimi_managed_import_is_read_only_until_hash_bound_approval() { |
| 726 | let _lock = crate::test_support::lock_test_env(); |
| 727 | let root = TempDir::new().unwrap(); |
| 728 | let codewhale_home = root.path().join("codewhale-home"); |
| 729 | let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 730 | let managed = root.path().join(".kimi-code/plugins/managed/kimi-demo"); |
| 731 | fs::create_dir_all(managed.join("skills/kimi-demo")).unwrap(); |
| 732 | fs::write( |
| 733 | managed.join("kimi.plugin.json"), |
| 734 | r#"{ |
| 735 | "name": "kimi-demo", |
| 736 | "version": "1.0.0", |
| 737 | "license": "Proprietary", |
| 738 | "skills": "./skills/", |
| 739 | "interface": { |
| 740 | "displayName": "Kimi Demo", |
| 741 | "hostKind": "local", |
| 742 | "platforms": ["macos"] |
| 743 | } |
| 744 | }"#, |
| 745 | ) |
| 746 | .unwrap(); |
| 747 | fs::write( |
| 748 | managed.join("skills/kimi-demo/SKILL.md"), |
| 749 | "---\nname: kimi-demo\ndescription: local managed fixture\n---\n", |
| 750 | ) |
| 751 | .unwrap(); |
| 752 | |
| 753 | let (mut app, _temp) = create_test_app(root.path()); |
| 754 | let help = plugins_with_kimi_home_override(&mut app, Some("help"), None) |
| 755 | .message |
| 756 | .unwrap(); |
| 757 | assert!(help.contains("/plugin import kimi [list]"), "{help}"); |
| 758 | let listed = plugins_with_kimi_home(&mut app, Some("import kimi"), root.path()); |
| 759 | assert!(!listed.is_error, "{:?}", listed.message); |
| 760 | let message = listed.message.unwrap(); |
| 761 | assert!( |
| 762 | message.contains("Kimi Demo") || message.contains("kimi-demo"), |
| 763 | "{message}" |
| 764 | ); |
| 765 | assert!(message.contains("license=Proprietary"), "{message}"); |
| 766 | assert!(message.contains("content hash:"), "{message}"); |
| 767 | assert!(message.contains("External Kimi apps"), "{message}"); |
| 768 | let approval = message |
| 769 | .lines() |
| 770 | .find_map(|line| line.trim().strip_prefix("approve: /plugin ")) |
| 771 | .expect("listing must render an exact approval command") |
| 772 | .to_string(); |
| 773 | assert!(app.plugin_registry.get("kimi-demo").is_none()); |
| 774 | assert!(!codewhale_home.join("plugins/kimi-demo").exists()); |
| 775 | assert!(!codewhale_home.join("plugins/state.json").exists()); |
| 776 | |
| 777 | // The approval token is tied to the bytes that were inspected. |
| 778 | fs::write( |
| 779 | managed.join("skills/kimi-demo/SKILL.md"), |
| 780 | "---\nname: kimi-demo\ndescription: changed fixture\n---\n", |
| 781 | ) |
| 782 | .unwrap(); |
| 783 | let changed = plugins_with_kimi_home(&mut app, Some(&approval), root.path()); |
| 784 | assert!(changed.is_error); |
| 785 | assert!(changed.message.unwrap().contains("changed since review")); |
| 786 | assert!(!codewhale_home.join("plugins/kimi-demo").exists()); |
| 787 | |
| 788 | let refreshed = plugins_with_kimi_home(&mut app, Some("import kimi"), root.path()) |
| 789 | .message |
| 790 | .unwrap(); |
| 791 | let approval = refreshed |
| 792 | .lines() |
| 793 | .find_map(|line| line.trim().strip_prefix("approve: /plugin ")) |
| 794 | .unwrap() |
| 795 | .to_string(); |
| 796 | let runtime = tokio::runtime::Builder::new_multi_thread() |
| 797 | .worker_threads(2) |
| 798 | .enable_all() |
| 799 | .build() |
| 800 | .unwrap(); |
| 801 | runtime.block_on(async { |
| 802 | let installed = plugins_with_kimi_home(&mut app, Some(&approval), root.path()); |
| 803 | assert!(!installed.is_error, "{:?}", installed.message); |
| 804 | assert!( |
| 805 | installed |
| 806 | .message |
| 807 | .as_deref() |
| 808 | .is_some_and(|message| message.contains("disabled and untrusted")) |
| 809 | ); |
| 810 | }); |
| 811 | let plugin = app.plugin_registry.get("kimi-demo").unwrap(); |
| 812 | assert!(!plugin.enabled && !plugin.trusted()); |
| 813 | assert!( |
| 814 | codewhale_home |
| 815 | .join("plugins/kimi-demo/kimi.plugin.json") |
| 816 | .is_file() |
| 817 | ); |
| 818 | } |
| 819 | |
| 820 | #[test] |
| 821 | fn kimi_managed_import_renders_in_the_selected_non_english_locale() { |
| 822 | let root = TempDir::new().unwrap(); |
| 823 | let (mut app, _temp) = create_test_app(root.path()); |
| 824 | app.ui_locale = Locale::Es419; |
| 825 | |
| 826 | let message = plugins_with_kimi_home(&mut app, Some("import kimi"), root.path()) |
| 827 | .message |
| 828 | .expect("localized Kimi listing"); |
| 829 | assert!( |
| 830 | message.contains("Plugins gestionados por Kimi"), |
| 831 | "{message}" |
| 832 | ); |
| 833 | assert!( |
| 834 | message.contains("No se encontraron plugins gestionados válidos"), |
| 835 | "{message}" |
| 836 | ); |
| 837 | assert!( |
| 838 | !message.contains("No valid managed plugins found"), |
| 839 | "{message}" |
| 840 | ); |
| 841 | } |
| 842 | |
| 843 | #[cfg(unix)] |
| 844 | #[test] |
| 845 | fn kimi_managed_import_refuses_linked_children() { |
| 846 | use std::os::unix::fs::symlink; |
| 847 | |
| 848 | let root = TempDir::new().unwrap(); |
| 849 | let managed_root = root.path().join(".kimi-code/plugins/managed"); |
| 850 | let outside = root.path().join("outside"); |
| 851 | fs::create_dir_all(&managed_root).unwrap(); |
| 852 | fs::create_dir_all(&outside).unwrap(); |
| 853 | symlink(&outside, managed_root.join("linked-plugin")).unwrap(); |
| 854 | let (mut app, _temp) = create_test_app(root.path()); |
| 855 | |
| 856 | let result = plugins_with_kimi_home(&mut app, Some("import kimi"), root.path()); |
| 857 | assert!(!result.is_error); |
| 858 | let message = result.message.unwrap(); |
| 859 | assert!(message.contains("Rejected entries"), "{message}"); |
| 860 | assert!(message.contains("links and reparse points are refused")); |
| 861 | assert!(!message.contains("approve: /plugin import kimi approve linked-plugin")); |
| 862 | } |
| 863 | |
| 864 | #[test] |
| 865 | fn export_verb_writes_agent_plugins_bundle() { |
| 866 | let _lock = crate::test_support::lock_test_env(); |
| 867 | let root = TempDir::new().unwrap(); |
| 868 | let codewhale_home = root.path().join("home"); |
| 869 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 870 | write_bundle(root.path()); |
| 871 | let (mut app, _temp) = create_test_app(root.path()); |
| 872 | |
| 873 | let usage = plugins_with_kimi_home_override(&mut app, Some("export"), None); |
| 874 | assert!(usage.is_error, "export without arguments is a usage error"); |
| 875 | let missing = plugins_with_kimi_home_override(&mut app, Some("export nope out"), None); |
| 876 | assert!(missing.is_error, "exporting an unknown plugin fails"); |
| 877 | |
| 878 | let result = plugins_with_kimi_home_override(&mut app, Some("export demo exported/demo"), None); |
| 879 | assert!(!result.is_error, "{result:?}"); |
| 880 | let message = result.message.expect("export message"); |
| 881 | assert!(message.contains("Exported `demo`"), "{message}"); |
| 882 | assert!(message.contains("plugin.json"), "{message}"); |
| 883 | |
| 884 | let bundle = root.path().join("exported/demo"); |
| 885 | let plugin_json: serde_json::Value = |
| 886 | serde_json::from_str(&fs::read_to_string(bundle.join("plugin.json")).unwrap()).unwrap(); |
| 887 | crate::plugins::agent_plugin::validate_plugin_json(&plugin_json).unwrap(); |
| 888 | assert_eq!(plugin_json["name"], "demo"); |
| 889 | assert_eq!(plugin_json["description"], "Import spreadsheet data safely"); |
| 890 | assert!(bundle.join("skills/hello/SKILL.md").is_file()); |
| 891 | assert!(!bundle.join("plugin.toml").exists()); |
| 892 | // No MCP servers declared, so no mcp.json is written. |
| 893 | assert!(!bundle.join("mcp.json").exists()); |
| 894 | // The installed bundle keeps its legacy manifest and stays untouched. |
| 895 | assert!( |
| 896 | root.path() |
| 897 | .join(".codewhale/plugins/demo/plugin.toml") |
| 898 | .exists() |
| 899 | ); |
| 900 | } |
| 901 | |
| 902 | #[test] |
| 903 | fn plugin_dismissals_list_and_reset_both_kinds() { |
| 904 | let _lock = crate::test_support::lock_test_env(); |
| 905 | let root = TempDir::new().unwrap(); |
| 906 | let codewhale_home = root.path().join("home"); |
| 907 | fs::create_dir_all(&codewhale_home).unwrap(); |
| 908 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 909 | let (mut app, _temp) = create_test_app(root.path()); |
| 910 | crate::settings::Settings::transact_opt(|settings| { |
| 911 | Ok(settings |
| 912 | .dismissed_plugin_suggestions |
| 913 | .insert("keptaway".to_string()) |
| 914 | .then_some(())) |
| 915 | }) |
| 916 | .unwrap(); |
| 917 | app.plugin_cta.dismissed.insert("keptaway".to_string()); |
| 918 | app.plugin_cta.dismissed.insert("esconce".to_string()); |
| 919 | |
| 920 | let listed = plugins_with_kimi_home_override(&mut app, Some("dismissals"), None) |
| 921 | .message |
| 922 | .expect("dismissal list"); |
| 923 | let kept = listed |
| 924 | .find("keptaway") |
| 925 | .unwrap_or_else(|| panic!("{listed}")); |
| 926 | let session = listed.find("esconce").unwrap_or_else(|| panic!("{listed}")); |
| 927 | assert!(listed.contains("Don't suggest again"), "{listed}"); |
| 928 | assert!(listed.contains("This session only"), "{listed}"); |
| 929 | assert!(kept < session, "{listed}"); |
| 930 | |
| 931 | let reset = plugins_with_kimi_home_override(&mut app, Some("dismissals reset KeptAway"), None) |
| 932 | .message |
| 933 | .expect("reset receipt"); |
| 934 | assert!(reset.contains("keptaway"), "{reset}"); |
| 935 | assert!( |
| 936 | crate::settings::Settings::load() |
| 937 | .unwrap() |
| 938 | .dismissed_plugin_suggestions |
| 939 | .is_empty(), |
| 940 | "reset must reach the saved choice" |
| 941 | ); |
| 942 | assert!(!app.plugin_cta.dismissed.contains("keptaway")); |
| 943 | assert!(app.plugin_cta.dismissed.contains("esconce")); |
| 944 | |
| 945 | plugins_with_kimi_home_override(&mut app, Some("dismissals reset"), None); |
| 946 | assert!(app.plugin_cta.dismissed.is_empty()); |
| 947 | let empty = plugins_with_kimi_home_override(&mut app, Some("dismissals"), None) |
| 948 | .message |
| 949 | .expect("empty list"); |
| 950 | assert!(empty.contains("No plugins are hidden"), "{empty}"); |
| 951 | } |
| 952 |