返回 CodeWhale
marketplace_tests.rs
根目录 / crates / tui / src / commands / groups / plugins / marketplace_tests.rs
1 //! Command-surface tests for `/plugin marketplace` (#5311): add/list/show/
2 //! remove over real catalog schemas, install routed through the existing
3 //! reviewed installer, and the no-auto-anything invariants.
4
5 use super::*;
6 use crate::config::Config;
7 use crate::tui::app::{App, TuiOptions};
8 use codewhale_localization::Locale;
9 use std::fs;
10 use std::path::Path;
11 use tempfile::TempDir;
12
13 fn create_test_app(root: &Path) -> (App, TempDir) {
14 let temp = TempDir::new().expect("tempdir");
15 let tools_dir = root.join("tools");
16 fs::create_dir_all(&tools_dir).unwrap();
17 let config_path = temp.path().join("config.toml");
18 fs::write(&config_path, "[tools]\n").unwrap();
19 let options = TuiOptions {
20 config_path: Some(config_path),
21 skills_dir: temp.path().join("skills"),
22 memory_path: temp.path().join("memory.md"),
23 notes_path: temp.path().join("notes.txt"),
24 mcp_config_path: temp.path().join("mcp.json"),
25 ..crate::test_support::test_tui_options(root)
26 };
27 let config = Config::default();
28 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
29 let registry = discovery.registry_for_workspace(root);
30 let mut app = App::new_with_plugin_registry(options, &config, registry);
31 app.ui_locale = Locale::En;
32 (app, temp)
33 }
34
35 /// A real-schema Kimi catalog: two entries, one local (installable via the
36 /// reviewed installer once a bundle exists) and one zip URL (honestly
37 /// unsupported until fetch support exists).
38 fn write_kimi_catalog(dir: &Path) -> std::path::PathBuf {
39 let catalog = serde_json::json!({
40 "version": "2",
41 "plugins": [
42 {
43 "id": "demo-bundle",
44 "source": "./demo-bundle",
45 "tier": "official",
46 "displayName": "Demo Bundle",
47 "version": "1.2.3",
48 "description": "Local demo bundle",
49 "homepage": "https://example.invalid/demo",
50 "keywords": ["demo"]
51 },
52 {
53 "id": "remote-thing",
54 "source": "https://example.invalid/remote-thing.zip",
55 "tier": "curated",
56 "displayName": "Remote Thing"
57 }
58 ]
59 });
60 let path = dir.join("kimi-marketplace.json");
61 fs::write(&path, serde_json::to_string_pretty(&catalog).unwrap()).unwrap();
62 path
63 }
64
65 fn write_demo_bundle(dir: &Path) {
66 let bundle = dir.join("demo-bundle");
67 fs::create_dir_all(bundle.join("skills/hello")).unwrap();
68 fs::write(
69 bundle.join("plugin.toml"),
70 "schema_version = 1\n[plugin]\nname = \"demo-bundle\"\nversion = \"1.0.0\"\ndescription = \"Demo\"\n[skills]\npath = \"skills\"\n",
71 )
72 .unwrap();
73 fs::write(
74 bundle.join("skills/hello/SKILL.md"),
75 "---\nname: hello\ndescription: hello\n---\nbody\n",
76 )
77 .unwrap();
78 }
79
80 fn marketplace_state_path(codewhale_home: &Path) -> std::path::PathBuf {
81 codewhale_home.join("plugins/marketplaces.json")
82 }
83
84 #[test]
85 fn marketplace_builtin_candidate_routes_to_existing_bundle_review() {
86 let _lock = crate::test_support::lock_test_env();
87 let root = TempDir::new().unwrap();
88 let codewhale_home = root.path().join("home");
89 fs::create_dir_all(&codewhale_home).unwrap();
90 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
91 let (mut app, _temp) = create_test_app(root.path());
92 let original = app
93 .plugin_registry
94 .get("computer-use")
95 .expect("shipped bundle")
96 .clone();
97 let result =
98 plugins_with_kimi_home_override(&mut app, Some("marketplace show codewhale"), None);
99 assert!(!result.is_error);
100 let text = result.message.unwrap();
101 assert!(
102 !text.contains(r"/plugin marketplace install codewhale computer\-use"),
103 "{text}"
104 );
105 assert!(
106 text.contains(&format!(
107 "/plugin show {}",
108 escape_review_text(original.id.as_str())
109 )),
110 "{text}"
111 );
112 assert!(
113 text.contains("/plugin marketplace install codewhale whalewiki"),
114 "{text}"
115 );
116 let result = plugins_with_kimi_home_override(
117 &mut app,
118 Some("marketplace install codewhale computer-use"),
119 None,
120 );
121 assert!(result.is_error);
122 assert!(result.message.unwrap().contains("already exists"));
123 let retained = app.plugin_registry.get("computer-use").unwrap();
124 assert_eq!(retained.content_hash, original.content_hash);
125 assert_eq!(retained.trust_status, original.trust_status);
126 assert_eq!(retained.enabled, original.enabled);
127 assert!(!codewhale_home.join("plugins/computer-use").exists());
128 }
129
130 #[test]
131 fn marketplace_add_list_show_remove_roundtrip() {
132 let _lock = crate::test_support::lock_test_env();
133 let root = TempDir::new().unwrap();
134 let codewhale_home = root.path().join("home");
135 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
136 let (mut app, _temp) = create_test_app(root.path());
137 let catalogs = root.path().join("catalogs");
138 fs::create_dir_all(&catalogs).unwrap();
139 let catalog_path = write_kimi_catalog(&catalogs);
140
141 // Usage errors are honest before anything is touched.
142 assert!(!plugins_with_kimi_home_override(&mut app, Some("marketplace"), None).is_error); // list, empty
143 assert!(plugins_with_kimi_home_override(&mut app, Some("marketplace add"), None).is_error);
144 assert!(
145 plugins_with_kimi_home_override(&mut app, Some("marketplace add 'bad name' x"), None)
146 .is_error
147 );
148
149 let added = plugins_with_kimi_home_override(
150 &mut app,
151 Some(&format!("marketplace add kimi {}", catalog_path.display())),
152 None,
153 );
154 assert!(!added.is_error, "{:?}", added.message);
155 let message = added.message.unwrap();
156 assert!(message.contains("Added marketplace `kimi`"), "{message}");
157 assert!(message.contains("2 candidate(s)"), "{message}");
158 assert!(message.contains("display-only"), "{message}");
159 assert!(marketplace_state_path(&codewhale_home).exists());
160
161 let list = plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None)
162 .message
163 .unwrap();
164 eprintln!("LIST2 >>>{list}<<<");
165 assert!(list.contains("`kimi`"), "{list}");
166 assert!(list.contains(r"demo\-bundle"), "{list}");
167 assert!(list.contains(r"remote\-thing"), "{list}");
168 assert!(list.contains("tier=official"), "{list}");
169 assert!(list.contains("tier=curated"), "{list}");
170
171 // Stored plans keep stable codes; rendering resolves the current locale.
172 app.ui_locale = Locale::Es419;
173 let localized = plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None)
174 .message
175 .unwrap();
176 assert!(localized.contains("no admite paquetes ZIP"), "{localized}");
177 assert!(!localized.contains("kimi_zip_unsupported"), "{localized}");
178 assert!(
179 !localized.contains("ZIP bundles are not supported"),
180 "{localized}"
181 );
182 app.ui_locale = Locale::En;
183
184 let show = plugins_with_kimi_home_override(&mut app, Some("marketplace show kimi"), None)
185 .message
186 .unwrap();
187 assert!(show.contains("Demo Bundle"), "{show}");
188 assert!(show.contains(r"v1\.2\.3"), "{show}");
189 assert!(show.contains("catalogs"), "{show}");
190
191 // read-only verbs never rewrite the store
192 let before = fs::read_to_string(marketplace_state_path(&codewhale_home)).unwrap();
193 plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None);
194 plugins_with_kimi_home_override(&mut app, Some("marketplace show kimi"), None);
195 let after = fs::read_to_string(marketplace_state_path(&codewhale_home)).unwrap();
196 assert_eq!(
197 before, after,
198 "list/show must not rewrite marketplace state"
199 );
200
201 // Re-adding the same source is a refresh, not a duplicate: a catalog is
202 // keyed by its document, so the second add updates it in place. (The old
203 // refusal here is what produced a second snapshot of one marketplace
204 // under a hand-made name.)
205 let refreshed = plugins_with_kimi_home_override(
206 &mut app,
207 Some(&format!("marketplace add kimi {}", catalog_path.display())),
208 None,
209 );
210 assert!(!refreshed.is_error, "{:?}", refreshed.message);
211 // A *different* source under the same name is still refused.
212 let other_catalog = catalogs.join("other-marketplace.json");
213 fs::write(
214 &other_catalog,
215 serde_json::to_string_pretty(&serde_json::json!({
216 "version": "2",
217 "plugins": [
218 {
219 "id": "other-bundle",
220 "source": "./other-bundle",
221 "displayName": "Other Bundle"
222 }
223 ]
224 }))
225 .unwrap(),
226 )
227 .unwrap();
228 let clash = plugins_with_kimi_home_override(
229 &mut app,
230 Some(&format!("marketplace add kimi {}", other_catalog.display())),
231 None,
232 );
233 assert!(clash.is_error, "{:?}", clash.message);
234
235 let removed = plugins_with_kimi_home_override(&mut app, Some("marketplace remove kimi"), None);
236 assert!(!removed.is_error, "{:?}", removed.message);
237 assert!(
238 removed
239 .message
240 .unwrap()
241 .contains("Installed plugins and their trust state are unaffected")
242 );
243 assert!(
244 plugins_with_kimi_home_override(&mut app, Some("marketplace show kimi"), None).is_error
245 );
246 let empty = plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None)
247 .message
248 .unwrap();
249 assert!(
250 empty.contains("codewhale") && empty.contains("whalewiki"),
251 "{empty}"
252 );
253 }
254
255 #[test]
256 fn plugin_suggest_preserves_current_main_marketplace_candidates() {
257 let _lock = crate::test_support::lock_test_env();
258 let root = TempDir::new().unwrap();
259 let codewhale_home = root.path().join("home");
260 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
261 let (mut app, _temp) = create_test_app(root.path());
262 let catalogs = root.path().join("catalogs");
263 fs::create_dir_all(&catalogs).unwrap();
264 let catalog_path = write_kimi_catalog(&catalogs);
265
266 let added = plugins_with_kimi_home_override(
267 &mut app,
268 Some(&format!("marketplace add kimi {}", catalog_path.display())),
269 None,
270 );
271 assert!(!added.is_error, "{:?}", added.message);
272
273 let suggested = plugins_with_kimi_home_override(&mut app, Some("suggest demo"), None);
274 assert!(!suggested.is_error, "{suggested:?}");
275 let message = suggested.message.expect("suggestion message");
276 assert!(message.contains("Suggested plugins"), "{message}");
277 assert!(
278 message.contains(r"demo\-bundle — not installed"),
279 "{message}"
280 );
281 assert!(
282 message.contains("/plugin marketplace install kimi demo-bundle"),
283 "{message}"
284 );
285 assert!(message.contains("Nothing was installed, trusted, or enabled."));
286 assert!(app.plugin_registry.get("demo-bundle").is_none());
287 }
288
289 #[test]
290 fn marketplace_add_rejects_symlinks_and_bad_documents() {
291 let _lock = crate::test_support::lock_test_env();
292 let root = TempDir::new().unwrap();
293 let codewhale_home = root.path().join("home");
294 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
295 let (mut app, _temp) = create_test_app(root.path());
296 let catalogs = root.path().join("catalogs");
297 fs::create_dir_all(&catalogs).unwrap();
298 let catalog_path = write_kimi_catalog(&catalogs);
299
300 // missing file
301 let missing = plugins_with_kimi_home_override(
302 &mut app,
303 Some("marketplace add nope /nonexistent/x.json"),
304 None,
305 );
306 assert!(missing.is_error);
307
308 // symlink to a real catalog is refused, not followed
309 let link = catalogs.join("link.json");
310 #[cfg(unix)]
311 std::os::unix::fs::symlink(&catalog_path, &link).unwrap();
312 #[cfg(not(unix))]
313 fs::copy(&catalog_path, &link).unwrap();
314 #[cfg(unix)]
315 {
316 let symlinked = plugins_with_kimi_home_override(
317 &mut app,
318 Some(&format!("marketplace add evil {}", link.display())),
319 None,
320 );
321 assert!(symlinked.is_error);
322 assert!(symlinked.message.unwrap().contains("symlink"));
323 }
324
325 // a document with no documented markers fails honestly and is not stored
326 let junk = catalogs.join("junk.json");
327 fs::write(&junk, "{\"hello\": \"world\"}").unwrap();
328 let bad = plugins_with_kimi_home_override(
329 &mut app,
330 Some(&format!("marketplace add junk {}", junk.display())),
331 None,
332 );
333 assert!(bad.is_error);
334 assert!(bad.message.unwrap().contains("could not be parsed"));
335 assert!(
336 plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None)
337 .message
338 .unwrap()
339 .contains("codewhale")
340 );
341
342 // corrupt stored state fails closed and is never rewritten
343 let store_path = marketplace_state_path(&codewhale_home);
344 fs::create_dir_all(store_path.parent().unwrap()).unwrap();
345 fs::write(&store_path, "{ not json").unwrap();
346 let corrupt = plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None);
347 assert!(corrupt.is_error);
348 assert!(corrupt.message.unwrap().contains("fail-closed"));
349 assert_eq!(fs::read_to_string(&store_path).unwrap(), "{ not json");
350 }
351
352 #[test]
353 fn marketplace_install_routes_through_reviewed_installer() {
354 let _lock = crate::test_support::lock_test_env();
355 let root = TempDir::new().unwrap();
356 let codewhale_home = root.path().join("home");
357 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
358 let (mut app, _temp) = create_test_app(root.path());
359 let catalogs = root.path().join("catalogs");
360 fs::create_dir_all(&catalogs).unwrap();
361 write_kimi_catalog(&catalogs);
362 write_demo_bundle(&catalogs);
363 assert!(
364 !plugins_with_kimi_home_override(
365 &mut app,
366 Some("marketplace add kimi catalogs/kimi-marketplace.json"),
367 None
368 )
369 .is_error
370 );
371
372 // The unsupported plan is refused before any runtime or network work.
373 let remote = plugins_with_kimi_home_override(
374 &mut app,
375 Some("marketplace install kimi remote-thing"),
376 None,
377 );
378 assert!(remote.is_error);
379 assert!(remote.message.unwrap().contains("cannot be installed"));
380
381 let runtime = tokio::runtime::Builder::new_multi_thread()
382 .worker_threads(2)
383 .enable_all()
384 .build()
385 .unwrap();
386 runtime.block_on(async {
387 let installed = plugins_with_kimi_home_override(
388 &mut app,
389 Some("marketplace install kimi demo-bundle"),
390 None,
391 );
392 assert!(!installed.is_error, "{:?}", installed.message);
393 let message = installed.message.unwrap();
394 assert!(message.contains("disabled and untrusted"), "{message}");
395 assert!(
396 message
397 .lines()
398 .any(|line| line.starts_with("/plugin trust demo-bundle ")),
399 "marketplace install must route into the trust review:\n{message}"
400 );
401
402 // No auto-trust, no auto-enable, no inherited vendor trust.
403 let plugin = app.plugin_registry.get("demo-bundle").unwrap();
404 assert!(!plugin.enabled);
405 assert!(!plugin.trusted());
406 assert!(
407 codewhale_home
408 .join("plugins/demo-bundle/.installed-from")
409 .exists()
410 );
411 });
412 }
413
414 /// A Codex catalog whose entry declares `INSTALLED_BY_DEFAULT`: the policy is
415 /// visible but nothing is auto-installed, and the npm source stays honestly
416 /// unsupported.
417 #[test]
418 fn marketplace_codex_installed_by_default_never_auto_installs() {
419 let _lock = crate::test_support::lock_test_env();
420 let root = TempDir::new().unwrap();
421 let codewhale_home = root.path().join("home");
422 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
423 let (mut app, _temp) = create_test_app(root.path());
424 let catalogs = root.path().join("catalogs");
425 fs::create_dir_all(&catalogs).unwrap();
426 let codex = serde_json::json!({
427 "name": "codex-catalog",
428 "plugins": [
429 {
430 "name": "defaulted-thing",
431 "source": { "source": "npm", "package": "@scope/defaulted-thing" },
432 "policy": { "installation": "INSTALLED_BY_DEFAULT" }
433 }
434 ]
435 });
436 let path = catalogs.join("codex-marketplace.json");
437 fs::write(&path, serde_json::to_string_pretty(&codex).unwrap()).unwrap();
438
439 let added = plugins_with_kimi_home_override(
440 &mut app,
441 Some(&format!("marketplace add codex {}", path.display())),
442 None,
443 );
444 assert!(!added.is_error, "{:?}", added.message);
445
446 let list = plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None)
447 .message
448 .unwrap();
449 assert!(list.contains(r"defaulted\-thing"), "{list}");
450 assert!(list.contains("not installable"), "{list}");
451 assert!(list.contains("npm"), "{list}");
452 // Foreign auto-install policy never ran anything: no bundle on disk.
453 assert!(!codewhale_home.join("plugins/defaulted-thing").exists());
454 assert!(app.plugin_registry.get("defaulted-thing").is_none());
455 }
456
456 lines RUST